mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
overseer fixes
This commit is contained in:
@@ -16,7 +16,6 @@ llmCommentary:
|
|||||||
overseerControl:
|
overseerControl:
|
||||||
enabled: false
|
enabled: false
|
||||||
observeOnly: true
|
observeOnly: true
|
||||||
alwaysRunModel: false
|
|
||||||
postToolsOnlyMessages: false
|
postToolsOnlyMessages: false
|
||||||
tiebreakerEnable: false
|
tiebreakerEnable: false
|
||||||
runWhileNoPeopleOnline: false
|
runWhileNoPeopleOnline: false
|
||||||
@@ -25,8 +24,6 @@ overseerControl:
|
|||||||
ollamaServer: "http://127.0.0.1:11434"
|
ollamaServer: "http://127.0.0.1:11434"
|
||||||
profileImageUrl: "https://example.com/overseer.png"
|
profileImageUrl: "https://example.com/overseer.png"
|
||||||
gateIntervalMs: 2000
|
gateIntervalMs: 2000
|
||||||
heartbeatMs: 30000
|
|
||||||
postChatDelayMs: 20000
|
|
||||||
media:
|
media:
|
||||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||||
# http://<base>/<roverId>/whep
|
# http://<base>/<roverId>/whep
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ const path = require('path');
|
|||||||
const PROMPT_PATH = path.join(__dirname, '..', '..', '..', 'prompts', 'overseer_control_system.txt');
|
const PROMPT_PATH = path.join(__dirname, '..', '..', '..', 'prompts', 'overseer_control_system.txt');
|
||||||
const DEFAULT_NAME = 'The Overseer';
|
const DEFAULT_NAME = 'The Overseer';
|
||||||
const DEFAULT_GATE_INTERVAL_MS = 2000;
|
const DEFAULT_GATE_INTERVAL_MS = 2000;
|
||||||
const DEFAULT_HEARTBEAT_MS = 30000;
|
|
||||||
const DEFAULT_POST_CHAT_DELAY_MS = 20000;
|
|
||||||
const MIN_INTERVAL_MS = 250;
|
const MIN_INTERVAL_MS = 250;
|
||||||
const MAX_RUN_HISTORY = 100;
|
const MAX_RUN_HISTORY = 100;
|
||||||
const MAX_CHAT_CONTEXT = 12;
|
const MAX_CHAT_CONTEXT = 12;
|
||||||
@@ -19,8 +17,6 @@ module.exports = {
|
|||||||
PROMPT_PATH,
|
PROMPT_PATH,
|
||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
DEFAULT_GATE_INTERVAL_MS,
|
DEFAULT_GATE_INTERVAL_MS,
|
||||||
DEFAULT_HEARTBEAT_MS,
|
|
||||||
DEFAULT_POST_CHAT_DELAY_MS,
|
|
||||||
MAX_RUN_HISTORY,
|
MAX_RUN_HISTORY,
|
||||||
MAX_CHAT_CONTEXT,
|
MAX_CHAT_CONTEXT,
|
||||||
MAX_BOT_CONTEXT,
|
MAX_BOT_CONTEXT,
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ const {
|
|||||||
PROMPT_PATH,
|
PROMPT_PATH,
|
||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
DEFAULT_GATE_INTERVAL_MS,
|
DEFAULT_GATE_INTERVAL_MS,
|
||||||
DEFAULT_HEARTBEAT_MS,
|
|
||||||
DEFAULT_POST_CHAT_DELAY_MS,
|
|
||||||
MAX_RUN_HISTORY,
|
MAX_RUN_HISTORY,
|
||||||
MAX_CHAT_CONTEXT,
|
MAX_CHAT_CONTEXT,
|
||||||
MAX_BOT_CONTEXT,
|
MAX_BOT_CONTEXT,
|
||||||
@@ -40,9 +38,6 @@ const name = String(overseerConfig.name || DEFAULT_NAME).trim() || DEFAULT_NAME;
|
|||||||
const model = String(overseerConfig.model || '').trim();
|
const model = String(overseerConfig.model || '').trim();
|
||||||
const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer || '').trim();
|
const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer || '').trim();
|
||||||
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
|
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
|
||||||
const heartbeatMs = normalizeMs(Number(overseerConfig.heartbeatMs), DEFAULT_HEARTBEAT_MS);
|
|
||||||
const postChatDelayMs = normalizeMs(Number(overseerConfig.postChatDelayMs), DEFAULT_POST_CHAT_DELAY_MS);
|
|
||||||
const alwaysRunModel = Boolean(overseerConfig.alwaysRunModel);
|
|
||||||
const postToolsOnlyMessages = Boolean(overseerConfig.postToolsOnlyMessages);
|
const postToolsOnlyMessages = Boolean(overseerConfig.postToolsOnlyMessages);
|
||||||
const tiebreakerEnable = Boolean(overseerConfig.tiebreakerEnable);
|
const tiebreakerEnable = Boolean(overseerConfig.tiebreakerEnable);
|
||||||
const runWhileNoPeopleOnline = Boolean(overseerConfig.runWhileNoPeopleOnline);
|
const runWhileNoPeopleOnline = Boolean(overseerConfig.runWhileNoPeopleOnline);
|
||||||
@@ -53,7 +48,6 @@ const runtime = {
|
|||||||
timer: null,
|
timer: null,
|
||||||
inFlight: false,
|
inFlight: false,
|
||||||
tickCount: 0,
|
tickCount: 0,
|
||||||
lastModelAt: 0,
|
|
||||||
generationCount: 0,
|
generationCount: 0,
|
||||||
generationTotalMs: 0,
|
generationTotalMs: 0,
|
||||||
runHistory: [],
|
runHistory: [],
|
||||||
@@ -70,9 +64,6 @@ let status = {
|
|||||||
ollamaUrl,
|
ollamaUrl,
|
||||||
promptPath: PROMPT_PATH,
|
promptPath: PROMPT_PATH,
|
||||||
gateIntervalMs,
|
gateIntervalMs,
|
||||||
heartbeatMs,
|
|
||||||
postChatDelayMs,
|
|
||||||
alwaysRunModel,
|
|
||||||
postToolsOnlyMessages,
|
postToolsOnlyMessages,
|
||||||
tiebreakerEnable,
|
tiebreakerEnable,
|
||||||
runWhileNoPeopleOnline,
|
runWhileNoPeopleOnline,
|
||||||
@@ -215,20 +206,6 @@ function buildRosterSummary() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeTriggerReason() {
|
|
||||||
if (alwaysRunModel) return 'loop_tick';
|
|
||||||
const recent = getRecentMessages(1, { includeSystem: false });
|
|
||||||
const last = recent[recent.length - 1];
|
|
||||||
if (last && Number(last.ts || 0) < runtime.contextResetAt) return null;
|
|
||||||
if (last && Date.now() - Number(last.ts || 0) < 5000) {
|
|
||||||
const txt = String(last.text || '').toLowerCase();
|
|
||||||
if (txt.includes(name.toLowerCase()) || txt.includes('overseer') || txt.includes('bot')) return 'direct_address';
|
|
||||||
return 'chat_activity';
|
|
||||||
}
|
|
||||||
if (!runtime.lastModelAt || Date.now() - runtime.lastModelAt >= heartbeatMs) return 'heartbeat';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeToolCalls(payload = null) {
|
function normalizeToolCalls(payload = null) {
|
||||||
const calls = Array.isArray(payload?.message?.tool_calls) ? payload.message.tool_calls : [];
|
const calls = Array.isArray(payload?.message?.tool_calls) ? payload.message.tool_calls : [];
|
||||||
return calls
|
return calls
|
||||||
@@ -385,7 +362,6 @@ async function runDecision(triggerReason) {
|
|||||||
runtime.generationCount += 1;
|
runtime.generationCount += 1;
|
||||||
runtime.generationTotalMs += generationMs;
|
runtime.generationTotalMs += generationMs;
|
||||||
const avgGenerationMs = Math.round(runtime.generationTotalMs / runtime.generationCount);
|
const avgGenerationMs = Math.round(runtime.generationTotalMs / runtime.generationCount);
|
||||||
runtime.lastModelAt = Date.now();
|
|
||||||
|
|
||||||
const actionResults = [];
|
const actionResults = [];
|
||||||
const requestedActions = toolCalls;
|
const requestedActions = toolCalls;
|
||||||
@@ -472,21 +448,45 @@ async function runDecision(triggerReason) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function tick() {
|
async function tick() {
|
||||||
|
// The scheduler is deliberately loop-only. Older versions mixed chat-triggered
|
||||||
|
// and heartbeat-triggered paths into this same timer, which made it possible
|
||||||
|
// for one slow model generation to be followed by another run that was still
|
||||||
|
// reasoning over the same chat message. In the loop model, every completed
|
||||||
|
// timer means exactly one model request, and the next timer is not installed
|
||||||
|
// until the current request has completely finished.
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
// setTimeout handles are one-shot. Clearing the reference at the beginning of
|
||||||
|
// the callback makes scheduler ownership obvious: if any gate reevaluation
|
||||||
|
// happens while this tick is running, startScheduler can see that work is
|
||||||
|
// already in-flight and will not create a competing timer.
|
||||||
|
runtime.timer = null;
|
||||||
|
|
||||||
|
if (runtime.inFlight) {
|
||||||
|
// This is a defensive guard for unusual event-loop races or manual calls.
|
||||||
|
// Sending the same model input twice is worse than skipping one interval,
|
||||||
|
// so a re-entrant tick is ignored and the active tick remains responsible
|
||||||
|
// for scheduling the next pass.
|
||||||
|
updateStatus({
|
||||||
|
phase: 'idle',
|
||||||
|
lastOutcome: 'skipped',
|
||||||
|
lastReason: 'loop tick skipped while model request is in flight',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
runtime.tickCount += 1;
|
runtime.tickCount += 1;
|
||||||
runtime.inFlight = true;
|
runtime.inFlight = true;
|
||||||
updateStatus({ inFlight: true, tickCount: runtime.tickCount, lastTickAt: Date.now(), phase: 'gate_check' });
|
updateStatus({
|
||||||
|
inFlight: true,
|
||||||
|
tickCount: runtime.tickCount,
|
||||||
|
lastTickAt: startedAt,
|
||||||
|
phase: 'loop_tick',
|
||||||
|
lastTriggerReason: 'loop_tick',
|
||||||
|
});
|
||||||
|
|
||||||
let nextDelayMs = gateIntervalMs;
|
|
||||||
try {
|
try {
|
||||||
const triggerReason = computeTriggerReason();
|
await runDecision('loop_tick');
|
||||||
if (!triggerReason) {
|
|
||||||
updateStatus({ phase: 'idle', lastOutcome: 'skipped', lastReason: 'gate not triggered' });
|
|
||||||
} else {
|
|
||||||
const runResult = await runDecision(triggerReason);
|
|
||||||
if (runResult?.postedChat) {
|
|
||||||
nextDelayMs = postChatDelayMs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const failure = buildFailureInfo(err);
|
const failure = buildFailureInfo(err);
|
||||||
updateStatus({
|
updateStatus({
|
||||||
@@ -500,8 +500,13 @@ async function tick() {
|
|||||||
} finally {
|
} finally {
|
||||||
runtime.inFlight = false;
|
runtime.inFlight = false;
|
||||||
if (status.running) {
|
if (status.running) {
|
||||||
updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt: Date.now() + nextDelayMs });
|
const nextRunAt = Date.now() + gateIntervalMs;
|
||||||
runtime.timer = setTimeout(tick, nextDelayMs);
|
updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt });
|
||||||
|
|
||||||
|
// The next timer is installed after all async work completes. This keeps
|
||||||
|
// Ollama calls strictly serialized even when a generation takes longer
|
||||||
|
// than gateIntervalMs.
|
||||||
|
runtime.timer = setTimeout(tick, gateIntervalMs);
|
||||||
} else {
|
} else {
|
||||||
updateStatus({ inFlight: false, currentRunId: null, nextRunAt: null });
|
updateStatus({ inFlight: false, currentRunId: null, nextRunAt: null });
|
||||||
}
|
}
|
||||||
@@ -521,7 +526,6 @@ function clearHistory(reason = 'admin requested clear history', options = {}) {
|
|||||||
runtime.liveToolCalls = [];
|
runtime.liveToolCalls = [];
|
||||||
runtime.generationCount = 0;
|
runtime.generationCount = 0;
|
||||||
runtime.generationTotalMs = 0;
|
runtime.generationTotalMs = 0;
|
||||||
runtime.lastModelAt = 0;
|
|
||||||
if (resetPersistentMemory) {
|
if (resetPersistentMemory) {
|
||||||
runtime.memoryStore = saveMemory(createDefaultMemory());
|
runtime.memoryStore = saveMemory(createDefaultMemory());
|
||||||
}
|
}
|
||||||
@@ -567,14 +571,17 @@ function stopScheduler(reason = 'paused') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startScheduler(reason = null) {
|
function startScheduler(reason = null) {
|
||||||
if (runtime.timer) return;
|
// Gate changes can happen while a loop iteration is still waiting on Ollama.
|
||||||
updateStatus({ running: true, phase: 'idle', lastReason: reason });
|
// In that case we mark the service as running again, but let the active tick's
|
||||||
|
// finally block install the next timer. That preserves a single owner for
|
||||||
|
// scheduling and prevents overlapping model requests.
|
||||||
|
updateStatus({ running: true, phase: runtime.inFlight ? status.phase : 'idle', lastReason: reason });
|
||||||
|
if (runtime.timer || runtime.inFlight) return;
|
||||||
runtime.timer = setTimeout(tick, gateIntervalMs);
|
runtime.timer = setTimeout(tick, gateIntervalMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluateSchedulerGate(reason = 'gate reevaluated') {
|
function evaluateSchedulerGate(reason = 'gate reevaluated') {
|
||||||
const voteStatus = buildVoteStatus();
|
const voteStatus = buildVoteStatus();
|
||||||
const runningAllowed = Boolean(enabled && voteStatus.gatePassed && getMode() !== MODES.LOCKDOWN);
|
|
||||||
updateStatus({ voteStatus });
|
updateStatus({ voteStatus });
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
stopScheduler('overseerControl.enabled is false');
|
stopScheduler('overseerControl.enabled is false');
|
||||||
@@ -640,7 +647,7 @@ if (!enabled) {
|
|||||||
logger.info('overseerControl paused on startup due to lockdown mode');
|
logger.info('overseerControl paused on startup due to lockdown mode');
|
||||||
} else {
|
} else {
|
||||||
evaluateSchedulerGate(observeOnly ? 'observe-only mode' : null);
|
evaluateSchedulerGate(observeOnly ? 'observe-only mode' : null);
|
||||||
logger.info('overseerControl enabled', { model, ollamaUrl, gateIntervalMs, heartbeatMs, observeOnly });
|
logger.info('overseerControl enabled', { model, ollamaUrl, gateIntervalMs, observeOnly });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ function buildAdminState(status, runHistory) {
|
|||||||
model: status.model,
|
model: status.model,
|
||||||
ollamaUrl: status.ollamaUrl,
|
ollamaUrl: status.ollamaUrl,
|
||||||
gateIntervalMs: status.gateIntervalMs,
|
gateIntervalMs: status.gateIntervalMs,
|
||||||
heartbeatMs: status.heartbeatMs,
|
|
||||||
alwaysRunModel: status.alwaysRunModel,
|
|
||||||
observeOnly: status.observeOnly,
|
observeOnly: status.observeOnly,
|
||||||
promptPath: status.promptPath,
|
promptPath: status.promptPath,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user