naisys
Version:
NAISYS - Autonomous AI agent runner with built-in context management and cost tracking
575 lines • 27.2 kB
JavaScript
import { LlmApiType, sleep, SUPER_ADMIN_USERNAME } from "@naisys/common";
import chalk from "chalk";
import * as readline from "readline";
import { PREEMPTIVE_COMPACTION_THRESHOLD_TOKENS, } from "../features/shell/session.js";
import { SPEND_LIMIT_TIMEOUT_SECONDS, SpendLimitError, } from "../llm/costTracker.js";
import { ContentSource } from "../llm/llmDtos.js";
import { createEscKeyListener } from "../utils/input/escKeyListener.js";
import { OutputColor } from "../utils/output/output.js";
import { indefiniteWait, isTimedWait, NextCommandAction, noWait, timedWait, } from "./commandRegistry.js";
export function createCommandLoop({ globalConfig }, { agentConfig }, commandHandler, promptBuilder, shellCommand, lynxService, contextManager, workspaces, llmService, systemMessage, output, logService, inputMode, runService, promptNotification, localUserId, mailService, chatService, hubClient, sessionService, modelService, desktopService, commandLoopState, startupAttachmentService, pagedOutputBuffer) {
let preemptiveCompactTimeout;
/** Tracks the current timed wait so preemptive compact can calculate remaining time */
let currentWait;
/** Remote pause — when true, the loop forces indefinite wait between iterations. */
let paused = false;
/** One-shot flag — when set, the next resolveWait bypasses the pause check
* so an LLM response can fire for this cycle before pause re-engages. */
let bypassPauseOnce = false;
async function run(abortSignal) {
output.commentAndLog(`AGENT STARTED`);
// Show Agent Config exept the agent prompt
output.commentAndLog(`Agent configured to use ${agentConfig().shellModel} model`);
const startupSummary = startupAttachmentService.getSummary();
if (startupSummary) {
output.commentAndLog(startupSummary);
}
// System messages are logged with source=null and are not replayed, so write the current one every run.
output.commentAndLog("System Message:");
output.write(systemMessage);
logService.write({
role: "system",
content: systemMessage,
type: "system",
});
let nextCommandAction = NextCommandAction.Continue;
let llmErrorCount = 0;
while (nextCommandAction !== NextCommandAction.ExitApplication &&
nextCommandAction !== NextCommandAction.SessionComplete &&
!abortSignal?.aborted) {
let wait;
({ nextCommandAction, wait } = await runSessionStartup(llmErrorCount));
while (nextCommandAction == NextCommandAction.Continue &&
!abortSignal?.aborted) {
({ nextCommandAction, wait, llmErrorCount } = await processOneIteration(wait, llmErrorCount));
}
if (nextCommandAction == NextCommandAction.CompactSession) {
commandLoopState.setState("Executing");
clearTimeout(preemptiveCompactTimeout);
lynxService.clear();
pagedOutputBuffer.clear();
contextManager.clear();
await runService.incrementSession();
nextCommandAction = NextCommandAction.Continue;
}
}
commandLoopState.setState("Ending");
clearTimeout(preemptiveCompactTimeout);
if (abortSignal?.aborted) {
output.commentAndLog(`AGENT STOPPED (${abortSignal.reason})`);
return String(abortSignal.reason);
}
else if (nextCommandAction === NextCommandAction.SessionComplete) {
output.commentAndLog(`AGENT SESSION COMPLETED`);
return "session-complete";
}
else {
output.commentAndLog(`AGENT EXITED`);
return "exit";
}
}
const CACHE_EXPIRY_MARGIN_SECONDS = 30;
function schedulePreemptiveCompact() {
clearTimeout(preemptiveCompactTimeout);
if (!globalConfig().compactSessionEnabled ||
!globalConfig().preemptiveCompactEnabled ||
!agentConfig().autoCompact ||
shellCommand.isShellSuspended()) {
return;
}
const shellModel = agentConfig().shellModel;
const model = modelService.getLlmModel(shellModel);
const cacheTtl = model.cacheTtlSeconds;
const lastQueryTime = contextManager.getLastQueryTime();
if (!cacheTtl ||
lastQueryTime <= 0 ||
contextManager.getTokenCount() <= PREEMPTIVE_COMPACTION_THRESHOLD_TOKENS) {
return;
}
const cacheExpiresAt = lastQueryTime + cacheTtl * 1000;
const compactAt = cacheExpiresAt - CACHE_EXPIRY_MARGIN_SECONDS * 1000;
const compactAfterMs = compactAt - Date.now();
if (compactAfterMs <= 0) {
return;
}
preemptiveCompactTimeout = setTimeout(() => {
// Skip if paused — clearTimeout in setPaused can miss a callback already in flight.
if (paused) {
return;
}
let remainingSeconds = 0;
if (currentWait) {
const elapsed = Math.round((Date.now() - currentWait.startTime) / 1000);
remainingSeconds = Math.max(0, currentWait.totalSeconds - elapsed);
}
promptNotification.notify({
wake: "always",
userId: localUserId,
contextCommands: [`ns-session preemptive-compact ${remainingSeconds}`],
});
}, compactAfterMs);
}
async function processNotifications(kind) {
const result = await promptNotification.processPending(localUserId, kind);
for (const item of result.output) {
if (item.type === "context") {
contextManager.append(item.text, ContentSource.Console);
}
else if (item.type === "error") {
output.errorAndLog(item.text);
}
else {
output.commentAndLog(item.text);
}
}
// nonCommand drain = operator-reviewable event → bounce to debug for review
if (kind === "nonCommand" && result.drained) {
inputMode.setDebug();
}
return {
contextCommands: result.contextCommands,
debugCommands: result.debugCommands,
};
}
async function runSessionStartup(llmErrorCount) {
let nextCommandAction = NextCommandAction.Continue;
let wait = undefined;
commandLoopState.setState("Initializing");
// This ensures output is appended to the llm context
inputMode.setLLM();
if (globalConfig().supervisorUrl) {
output.notice(`Supervisor available at ${globalConfig().supervisorUrl}`);
output.notice(` Sign in as '${SUPER_ADMIN_USERNAME}' with the passkey set during setup. Use --setup to change it.`);
}
output.commentAndLog("Use ns-help to see all available commands, and @ to talk directly to agent");
desktopService.logStartup();
// `stale` only fires on the iteration that consumed the entries; a
// post-compact second pass sees replayed=false and falls through below.
const { replayed, stale } = await sessionService.replayRestoreEntries();
if (!replayed) {
output.commentAndLog("Starting Context:");
}
// Check for mail that arrived before/during startup (e.g. task mail)
if (hubClient) {
// After successful startup, the hub sends startup messages, so wait a second for that to happen
await sleep(1000);
}
let checkMail = true;
let initialCommands = [];
if (!replayed) {
initialCommands = [
...structuredClone(agentConfig().initialCommands),
...sessionService.getResumeCommands(),
];
}
// Else we're resuming the session, and if it is stale then we need to load
// the previous context and either compact it now, or if it's small enough
// skip the compact and just start from where it left off
else if (stale &&
globalConfig().compactSessionEnabled &&
contextManager.getTokenCount() >= PREEMPTIVE_COMPACTION_THRESHOLD_TOKENS) {
// Compact now so the saved snapshot catches up. Skipped below the
// auto-compact threshold — not worth an LLM call.
output.commentAndLog("Retroactively compacting previous session due to staleness...");
initialCommands = ["ns-session compact"];
checkMail = false;
}
for (const initialCommand of initialCommands) {
try {
const prompt = await promptBuilder.getPrompt(noWait());
contextManager.append(prompt, ContentSource.ConsolePrompt);
({ nextCommandAction, wait } = await commandHandler.processCommand(prompt, [agentConfig().resolveConfigVars(initialCommand)]));
}
catch (e) {
handleErrorAndSwitchToDebugMode(e, llmErrorCount, true);
}
}
// Even if mail/chat not enabled, we may be sent mail/chat from agents that do have it enabled, and we
// need to receive these msgs and mark them as read as agents are auto-started to handle unread messages
if (checkMail) {
try {
await mailService.checkAndNotify();
await chatService.checkAndNotify();
// Reviewable events only; commands (rare here) wait for the main loop
await processNotifications("nonCommand");
}
catch (e) {
// Hub can flap during startup; don't crash the agent — let the main loop recover
handleErrorAndSwitchToDebugMode(e, llmErrorCount, true);
}
}
inputMode.setDebug();
return { nextCommandAction, wait };
}
async function processOneIteration(wait, llmErrorCount) {
wait = resolveWait(wait);
// Must be after the mode switch in resolveWait so the message lands in
// the right context (LLM/Debug)
if (shellCommand.isShellSuspended()) {
const elapsedTime = shellCommand.getCommandElapsedTimeString();
const commandName = shellCommand.getCurrentCommandName();
contextManager.append(`'${commandName}' has been running for ${elapsedTime}. Enter 'ns-wait <seconds>' to continue waiting. 'ns-kill' to terminate. Any other input will be sent directly to the running process.`, ContentSource.Console);
}
// Drain nonCommand up front so log messages render regardless of mode
// and contextOutput lands before getPrompt reads the token count. Without
// this, a wake stuck in a paused-debug iteration would re-fire the
// readline poll forever.
await processNotifications("nonCommand");
const prompt = await promptBuilder.getPrompt(wait);
let commandList = [];
let blankDebugInput = false;
// Debug command prompt, also handles llm triggered waits
if (inputMode.isDebug()) {
if (isTimedWait(wait)) {
currentWait = { startTime: Date.now(), totalSeconds: wait.seconds };
}
commandLoopState.setState("Waiting");
commandList = [
await promptBuilder.getInput(`${prompt}`, wait, () => {
// User started typing — cancel preemptive compact since a new
// LLM query will follow and reschedule with fresh timing
clearTimeout(preemptiveCompactTimeout);
}),
];
currentWait = undefined;
// If getInput was woken by a queued remote command, use it instead
// of letting the empty input trigger a mode switch.
if (commandList[0].trim().length === 0 &&
promptNotification.hasPending(localUserId, true, "debugCommand")) {
const drained = await processNotifications("debugCommand");
commandList = drained.debugCommands;
}
blankDebugInput =
commandList.length === 0 || commandList[0].trim().length === 0;
// readline echoed the prompt+input to stdout; mirror it to the log
// so supervisor UI viewers see debug-mode activity. Redact when the
// suspended shell is in a secure continuation (e.g. ns-pty password
// prompt) so credentials don't end up in the persisted log.
if (!blankDebugInput) {
const baseCmd = commandList[0].trim().split(/\s+/)[0];
const isSecure = shellCommand.isShellSuspended() &&
shellCommand.isSecureContinuation() &&
baseCmd !== "ns-wait" &&
baseCmd !== "ns-kill";
output.logOnly(prompt + (isSecure ? "******" : commandList[0]));
}
}
// LLM command prompt
else if (inputMode.isLLM()) {
wait = undefined;
const result = await getLlmCommands(prompt, llmErrorCount);
if (result.outcome === "skip") {
return {
nextCommandAction: NextCommandAction.Continue,
llmErrorCount: result.llmErrorCount,
wait: result.wait,
};
}
if (result.outcome === "desktop") {
// desktopService sets Confirming/Executing internally based on focus
await desktopService.confirmAndExecuteActions(result.desktop.textContent, result.desktop.actions);
return {
nextCommandAction: NextCommandAction.Continue,
wait: undefined,
llmErrorCount,
};
}
commandList = result.commands;
}
else {
throw `Unreachable: Invalid input mode`;
}
// Run the command
try {
commandLoopState.setState("Executing");
const commandResult = await commandHandler.processCommand(prompt, commandList);
if (inputMode.isLLM()) {
llmErrorCount = 0;
}
// If the user is in debug mode and they didn't enter anything, switch to LLM.
// Also switch immediately if the command requested it (e.g. ns-talk).
const shouldSwitchToLlm = blankDebugInput || commandResult.triggerLlm;
if (inputMode.isDebug() && shouldSwitchToLlm) {
inputMode.setLLM();
// Blank-line continuation and explicit triggerLlm commands (ns-talk)
// bypass pause for one cycle. Ordinary command waits still remain
// paused in resolveWait.
if (paused) {
bypassPauseOnce = true;
}
}
// If in LLM mode, auto switch back to debug
else if (inputMode.isLLM()) {
inputMode.setDebug();
}
return {
nextCommandAction: commandResult.nextCommandAction,
wait: commandResult.wait,
llmErrorCount,
};
}
catch (e) {
const errorResult = handleErrorAndSwitchToDebugMode(e, llmErrorCount, true);
return {
nextCommandAction: NextCommandAction.Continue,
llmErrorCount: errorResult.llmErrorCount,
wait: errorResult.wait,
};
}
}
async function getLlmCommands(prompt, llmErrorCount) {
const shellModel = agentConfig().shellModel;
const modelName = modelService.getLlmModel(shellModel)?.label || shellModel;
const workingMsg = prompt + chalk[OutputColor.loading](`LLM (${modelName}) Working...`);
let commands = [];
// contextCommands replace the LLM query (e.g. preemptive compact)
if (promptNotification.hasPending(localUserId, true, "contextCommand")) {
const drained = await processNotifications("contextCommand");
commands = drained.contextCommands;
}
// No-LLM agents have nothing to query — bounce back to debug unless
// we picked up commands from notifications above.
if (shellModel === LlmApiType.None && commands.length === 0) {
inputMode.setDebug();
return { outcome: "skip", llmErrorCount, wait: undefined };
}
checkContextLimitWarning();
if (agentConfig().workspacesEnabled && workspaces.hasFiles()) {
output.comment(workspaces.listFiles());
}
contextManager.append(prompt, ContentSource.ConsolePrompt);
if (commands.length > 0) {
return { outcome: "commands", commands };
}
try {
if (output.isConsoleEnabled()) {
process.stdout.write(workingMsg);
}
// Set up ESC key cancellation for LLM query
const queryController = new AbortController();
let stopEscListener = () => { };
// Only set up ESC listener if agent is in focus to avoid interfering with readline
if (output.isConsoleEnabled()) {
const escListener = createEscKeyListener();
stopEscListener = escListener.start(() => {
queryController.abort();
});
}
let queryCancelled = false;
try {
commandLoopState.setState("LlmQuerying");
const wasPausedBeforeQuery = paused;
const queryResult = await llmService.query(shellModel, systemMessage, contextManager.getCombinedMessages(), "console", queryController.signal);
// Clear "Working..." immediately so any subsequent output
// (token warnings, desktop requests, etc.) starts on a clean line
clearPromptMessage(workingMsg);
contextManager.setMessagesTokenCount(queryResult.messagesTokenCount);
if (paused) {
// Pause arrived mid-query — drop the response so the agent
// honors the pause. If we were already paused at start, this
// is a bypassPauseOnce response and is kept.
if (!wasPausedBeforeQuery) {
inputMode.setDebug();
return { outcome: "skip", llmErrorCount, wait: indefiniteWait() };
}
// Either way, no compact timer while paused.
}
else {
schedulePreemptiveCompact();
}
// Desktop actions: return for confirmation and execution
if (queryResult.desktopActions?.length) {
return {
outcome: "desktop",
desktop: {
textContent: queryResult.responses.join("\n"),
actions: queryResult.desktopActions,
},
};
}
commands = queryResult.responses;
}
catch (queryError) {
// Check if this was an ESC cancellation
if (queryController.signal.aborted) {
queryCancelled = true;
}
else {
throw queryError; // Re-throw non-abort errors
}
}
finally {
stopEscListener();
}
// Handle ESC cancellation
if (queryCancelled) {
clearPromptMessage(workingMsg);
output.commentAndLog("LLM query cancelled by ESC");
inputMode.setDebug();
return { outcome: "skip", llmErrorCount, wait: indefiniteWait() };
}
}
catch (e) {
// Clear "Working..." before printing error output
clearPromptMessage(workingMsg);
// Check if the error is a bad request (400) that might be caused by media content
// in the context (e.g. mismatched MIME type). Scrub non-text content from recent
// user messages so the agent can recover instead of getting stuck in an error loop.
const errorStr = `${e}`;
if (errorStr.includes("400") && contextManager.scrubRecentMedia()) {
output.errorAndLog(`Attempting Context Recovery: Recent media scrubbed from context due to API error: ${errorStr}`, e);
contextManager.append(`System: Recent media was scrubbed from the context because it caused an API error: ${errorStr.slice(0, 150)}`);
return { outcome: "skip", llmErrorCount, wait: undefined };
}
const errorResult = handleErrorAndSwitchToDebugMode(e, llmErrorCount, false);
return {
outcome: "skip",
llmErrorCount: errorResult.llmErrorCount,
wait: errorResult.wait,
};
}
return { outcome: "commands", commands };
}
function clearPromptMessage(_waitingMessage) {
if (output.isConsoleEnabled()) {
// Use cursorTo + clearLine instead of precise cursor math — robust
// even if unexpected output was written to stdout during the query
readline.cursorTo(process.stdout, 0);
readline.clearLine(process.stdout, 0);
}
}
/** Name is comically long because of a prettier formatting issue when the name is too short */
function handleErrorAndSwitchToDebugMode(e, llmErrorCount, addToContext) {
const maxErrorLength = 200;
const errorMsg = `${e}`;
if (addToContext) {
contextManager.append(errorMsg.slice(0, maxErrorLength));
if (errorMsg.length > maxErrorLength) {
contextManager.append("...");
output.errorAndLog(`Error too long for context: ${errorMsg.slice(200)}`, e);
}
}
else {
output.errorAndLog(errorMsg, e);
}
// If llm is in some error loop then hold in debug mode
let wait = getConfiguredDebugWait();
if (inputMode.isLLM()) {
if (e instanceof SpendLimitError) {
// Spend limit errors use a constant timeout since they resolve on a schedule
wait = timedWait(SPEND_LIMIT_TIMEOUT_SECONDS);
}
else {
llmErrorCount++;
({ wait, llmErrorCount } = getErrorBackoffWait(llmErrorCount));
}
}
inputMode.setDebug();
return {
llmErrorCount,
wait,
};
}
function getConfiguredDebugWait() {
const debugPauseSeconds = agentConfig().debugPauseSeconds;
if (debugPauseSeconds === undefined) {
return indefiniteWait();
}
return timedWait(debugPauseSeconds);
}
/** Picks this iteration's wait and any implied mode switch (pause→debug,
* unfocused→LLM). Preserves an already-set wait. */
function resolveWait(wait) {
const hasPendingDebugCommand = promptNotification.hasPending(localUserId, true, "debugCommand");
// Consume the one-shot bypass so pause re-engages next iteration.
const bypassPause = bypassPauseOnce;
bypassPauseOnce = false;
// Remote pause forces indefinite debug-mode wait between iterations,
// even when the previous command requested a timed/no wait. It remains
// interruptible via resume + prompt notifications.
if (paused && !bypassPause) {
inputMode.setDebug();
wait = indefiniteWait();
}
else if (wait === undefined) {
// Remote debug command queued — force debug mode so the drain logic
// below can consume it even on unfocused agents that would otherwise
// skip debug mode entirely.
if (hasPendingDebugCommand) {
inputMode.setDebug();
wait = noWait();
}
// When llm model is set to none then we should hold indefinitely
else if (agentConfig().shellModel === LlmApiType.None) {
wait = indefiniteWait();
}
// Unfocused agents keep processing commands without delay
else if (!output.isConsoleEnabled() && inputMode.isDebug()) {
inputMode.setLLM();
wait = noWait();
}
else {
wait = getConfiguredDebugWait();
}
}
// Fail safe — an unfocused agent on indefinite debug wait would hang
if (!paused &&
!hasPendingDebugCommand &&
inputMode.isDebug() &&
!output.isConsoleEnabled() &&
wait.kind === "indefinite" &&
agentConfig().shellModel !== LlmApiType.None) {
output.errorAndLog("Agent is unfocused with an indefinite wait, switching to LLM mode to avoid hanging.");
inputMode.setLLM();
wait = noWait();
}
return wait;
}
function getErrorBackoffWait(llmErrorCount) {
let backoffSeconds = globalConfig().retrySecondsBase * 2 ** (llmErrorCount - 1);
if (backoffSeconds > globalConfig().retrySecondsMax) {
backoffSeconds = globalConfig().retrySecondsMax;
llmErrorCount--; // Prevent overflowing the calculation above
}
return {
wait: timedWait(backoffSeconds),
llmErrorCount,
};
}
function checkContextLimitWarning() {
const tokenCount = contextManager.getTokenCount();
const tokenMax = agentConfig().tokenMax;
if (tokenCount > tokenMax) {
let tokenNote = "";
if (globalConfig().compactSessionEnabled) {
tokenNote += `\nUse 'ns-session compact' to reduce the token usage of the session.`;
}
contextManager.append(`The token limit for this session has been exceeded.${tokenNote}`, ContentSource.Console);
}
}
function setPaused(next) {
if (paused === next) {
return false;
}
paused = next;
// Paused → no upcoming query, drop the pending compact.
if (next) {
clearTimeout(preemptiveCompactTimeout);
}
// Wakes the indefinite wait, renders the comment, and bounces to debug
// for operator review (via the nonCommand drain rule).
promptNotification.notify({
userId: localUserId,
wake: "always",
commentOutput: [`Session ${next ? "Paused" : "Resumed"}`],
});
return true;
}
return {
run,
isPaused: () => paused,
setPaused,
cleanup: () => clearTimeout(preemptiveCompactTimeout),
};
}
//# sourceMappingURL=commandLoop.js.map