UNPKG

naisys

Version:

NAISYS - Autonomous AI agent runner with built-in context management and cost tracking

390 lines 18.3 kB
import { isAudioFilename, isImageFilename, mapWithConcurrency, mimeFromFilename, } from "@naisys/common"; import stringArgv from "string-argv"; import { chatCmd, commentCmd, mailCmd, sessionCmd, } from "../../command/commandDefs.js"; import { NextCommandAction, noWait, timedWait, } from "../../command/commandRegistry.js"; import { ContentSource } from "../../llm/llmDtos.js"; import { getTokenCount } from "../../utils/utilities.js"; /** Cap on concurrent hub attachment downloads during resume-replay so a * long-running agent's image-heavy log doesn't open dozens of sockets. */ const RESUME_DOWNLOAD_CONCURRENCY = 4; /** Optimal point to compact context. Waiting longer wastes money on bloated * reads; compacting sooner wastes money generating summaries too often. * Model min cacheable size agnostic. */ export const PREEMPTIVE_COMPACTION_THRESHOLD_TOKENS = 2400; export function createSessionService({ globalConfig }, { agentConfig }, shellCommand, output, contextManager, systemMessage, llmService, logService, hubAttachmentClient, inputMode, /** Resume bundle — hub already filtered by continuity + staleness. */ preloadedRestore) { let restoreInfo = preloadedRestore?.summary ?? ""; let resumeWaitSeconds; let pendingRestoreEntries = preloadedRestore?.entries; let restoreStale = preloadedRestore?.stale ?? false; const restoreCursor = preloadedRestore?.cursor; /** Gates `ns-session complete`: true iff the agent's last meaningful * (non-wait) command was a successful chat-send or mail-send. * * `complete` is a silent process exit — it produces no mail/chat * event, so on its own it can't wake an offline lead. The gate * forces a chat/mail send to immediately precede complete (waits * in between are fine), guaranteeing the auto-start path * (see docs/004) has an event to fire on. Without it, an agent * could finish its work and exit while the lead stayed asleep with * nothing to wake on — an effective hang. Waits preserve the flag; * any other command clears it. See `updateCanComplete`. */ let canComplete = false; if (restoreInfo) { output.commentAndLog(`Loaded restore summary from hub (${getTokenCount(restoreInfo)} tokens).`); } function hasPendingRestoreEntries() { return !!pendingRestoreEntries && pendingRestoreEntries.length > 0; } /** Consume the resume bundle. `replayed`/`stale` flip atomically so a * later iteration can't see stale=true after entries are gone. * `stale=true` → retro-compact after replay. The tail normally carries * the post-compact `ns-session restore` echo, so we scan for it and only * prepend the summary as a defensive fallback when missing. * Image/audio attachments rehydrate via appendImage/appendAudio, * fetched in parallel up front. */ async function replayRestoreEntries() { if (!pendingRestoreEntries || pendingRestoreEntries.length === 0) { return { replayed: false, stale: false }; } const entries = pendingRestoreEntries; const stale = restoreStale; pendingRestoreEntries = undefined; restoreStale = false; const cursorLabel = restoreCursor ? ` since run ${restoreCursor.runId} session ${restoreCursor.sessionId}` : ""; output.commentAndLog(`Replaying ${entries.length} entries from previous context${cursorLabel}...`); const silent = { skipLog: true }; // Defensive fallback. The post-compact `ns-session restore` echo // normally carries the summary into context via the tail. Hitting // this branch means the echo wasn't logged — buffer dropped, hub // write failed, restore didn't run, or some other regression. Log // loudly so it gets noticed. if (restoreInfo && !entries.some((entry) => entry.source === "console" && entry.message === restoreInfo)) { output.errorAndLog("Unexpected restore replay state: replayed entries did not include the compact summary; injecting fallback. This may indicate a restore logging bug."); contextManager.append(`[Restored summary]\n${restoreInfo}`, ContentSource.Console, silent); } const attachmentBuffers = await fetchAttachments(entries); for (const entry of entries) { const source = sourceFromString(entry.source); if (!source) continue; const buf = entry.attachment ? attachmentBuffers.get(entry.attachment.publicId) : undefined; if (entry.attachment && buf) { const { filename } = entry.attachment; const mime = mimeFromFilename(filename); const base64 = buf.toString("base64"); if (isImageFilename(filename)) { // Falls through to text on a model-block reason (e.g. shellModel // changed since the prior run and no longer accepts image context). const blockReason = contextManager.appendImage(base64, mime, filename, silent); if (blockReason) contextManager.append(entry.message, source, silent); } else if (isAudioFilename(filename)) { // Same fallback as images — guards against the agent recording audio // under a hearing-capable model and restarting under one that isn't. const blockReason = contextManager.appendAudio(base64, mime, filename, silent); if (blockReason) contextManager.append(entry.message, source, silent); } else { // Hub already filters to image/audio; fall back to text just in case. contextManager.append(entry.message, source, silent); } } else { contextManager.append(entry.message, source, silent); } } return { replayed: true, stale }; } /** Parallel-fetch (capped) all attachment bodies. Failures are swallowed * so a missing/stale attachment doesn't abort the whole replay — the * affected entry just falls back to its text message. */ async function fetchAttachments(entries) { const ids = Array.from(new Set(entries .map((e) => e.attachment?.publicId) .filter((id) => !!id))); if (ids.length === 0) return new Map(); output.commentAndLog(`Fetching ${ids.length} attachments for replay...`); const results = await mapWithConcurrency(ids, RESUME_DOWNLOAD_CONCURRENCY, async (id) => { try { return [id, await hubAttachmentClient.downloadToBuffer(id)]; } catch (e) { output.errorAndLog(`Failed to fetch resume attachment ${id}: ${e}`, e); return [id, null]; } }); const map = new Map(); for (const [id, buf] of results) { if (buf) map.set(id, buf); } return map; } /** Extracted from handleCompact so handleComplete can reuse the LLM call * without triggering a session restart. */ async function produceRestoreSummary() { contextManager.append("Process this session log and reduce it down to important things to remember - " + "references, plans, project structure, schemas, file locations, urls, and more. Focus on the near term, next logical steps. " + "Check for and fix any inconsistencies in the current context to avoid passing them on the next session. " + "The next session should be able to start with minimal bootstrapping or scanning of existing files. " + "What are the things the next session should do when restored - tasks, goals, etc.. And ensure it has " + "all the important context to do those things, as it will be starting from a blank slate. \n\n" + "# Write the restored-session seed below (no preamble)."); const queryResult = await llmService.query(agentConfig().shellModel, systemMessage, contextManager.getCombinedMessages(), "compact"); restoreInfo = queryResult.responses.join("\n"); logService.write({ role: "assistant", source: ContentSource.LLM, type: "compact", content: restoreInfo, }); } async function handleCommand(args) { const argv = stringArgv(args); const subcommand = argv[0]; if (!subcommand) { return getHelpText(); } switch (subcommand) { case "help": return getHelpText(); // Changed nomenclature from pause to wait to better reflect that the session can wake on events case "wait": case "continue-wait": return handleWait(argv[1]); case "compact": return handleCompact(); case "preemptive-compact": return handlePreemptiveCompact(argv[1]); case "restore": return handleRestore(); case "complete": return handleComplete(); case "clear": return handleClear(); default: return `Unknown subcommand: ${subcommand}\n\n${getHelpText()}`; } } function getHelpText() { const subs = sessionCmd.subcommands; let helpText = `${sessionCmd.name} <subcommand> ${subs.wait.usage.padEnd(20)}${subs.wait.description}`; if (globalConfig().compactSessionEnabled) { helpText += ` ${subs.compact.usage.padEnd(20)}${subs.compact.description}`; } if (agentConfig().completeSessionEnabled) { helpText += ` ${subs.complete.usage.padEnd(20)}${subs.complete.description}`; } if (inputMode.isDebug()) { helpText += ` ${subs.clear.usage.padEnd(20)}${subs.clear.description}`; } return helpText; } function handleWait(secondsArg) { const waitSeconds = secondsArg ? parseInt(secondsArg) : NaN; // Indefinite waits are kept internal so unattended agents do not hang the system. if (isNaN(waitSeconds) || waitSeconds <= 0) { return `Please specify the number of seconds to wait, for example: ns-session wait 60`; } return { content: "", nextCommandResponse: { nextCommandAction: NextCommandAction.Continue, wait: timedWait(waitSeconds), }, }; } async function handleCompact() { if (!globalConfig().compactSessionEnabled) { return 'The "ns-session compact" command is not enabled in this environment.'; } if (shellCommand.isShellSuspended()) { return "Session cannot be compacted while a shell command is active."; } output.commentAndLog(`Compacting...`); await produceRestoreSummary(); output.commentAndLog(`Session compacted to ${getTokenCount(restoreInfo)} tokens. Restarting Session.`); output.commentAndLog("------------------------------------------------------"); return { content: "", nextCommandResponse: { nextCommandAction: NextCommandAction.CompactSession, wait: noWait(), }, }; } async function handlePreemptiveCompact(remainingSecondsArg) { if (!globalConfig().preemptiveCompactEnabled) { throw new Error("Preemptive compact is not enabled"); } if (!agentConfig().autoCompact) { throw new Error("Auto compact is not enabled for this agent"); } const remaining = remainingSecondsArg ? parseInt(remainingSecondsArg) : 0; if (isNaN(remaining) || remaining < 0) { throw new Error("Preemptive compact requires a valid remaining seconds argument"); } if (remaining > 0) { output.commentAndLog(`Pre-emptively compacting session before read cache expires. Will continue waiting ${remaining} seconds on resume...`); resumeWaitSeconds = remaining; } else { output.commentAndLog(`Pre-emptively compacting session before read cache expires...`); } return handleCompact(); } function handleRestore() { if (!restoreInfo) { return "No session restore information available."; } return { content: restoreInfo, nextCommandResponse: { nextCommandAction: NextCommandAction.Continue, }, }; } /** Like compact but no LLM call — writes a `type: "compact"` row whose * text is shown to the next session as its restored summary. The rest * flows through compact's normal path: hub mirror advances * `compact_log_id`, the restart queues `ns-session restore`, the echo * lands in the tail. Hidden from non-debug help but callable by the * agent if asked. */ function handleClear() { const clearMessage = "Previous Session Cleared"; logService.write({ role: "assistant", source: ContentSource.LLM, type: "compact", content: clearMessage, }); // Non-empty so getResumeCommands queues `ns-session restore` on the // restart and the echo logs into the tail — otherwise next // AGENT_START's scan misses the echo and trips the defensive fallback. restoreInfo = clearMessage; output.commentAndLog("Session Cleared — restarting with no context history or summary."); output.commentAndLog("------------------------------------------------------"); return { content: "", nextCommandResponse: { nextCommandAction: NextCommandAction.CompactSession, wait: noWait(), }, }; } async function handleComplete() { if (!agentConfig().completeSessionEnabled) { return 'The "ns-session complete" command is not enabled for you, please use wait command instead.'; } // The send is what wakes an offline recipient via auto-start; complete // itself is silent. See the `canComplete` doc above for the full why. if (!canComplete) { return `Report results to your lead, admin, or someone else via chat or mail before calling complete.`; } // Capture a fresh seed before exit so the next run can resume. Skip // the LLM call when context is small — next session's RestoreData // replays the raw entries fine at this size. Symmetric to the // retro-compact skip in commandLoop.runSessionStartup. if (agentConfig().continuity === "summary") { if (contextManager.getTokenCount() < PREEMPTIVE_COMPACTION_THRESHOLD_TOKENS) { output.commentAndLog("Context below compact threshold, skipping summary."); } else { output.commentAndLog("Saving session summary before completing..."); try { await produceRestoreSummary(); } catch (e) { output.errorAndLog(`Failed to produce restore summary: ${e}`, e); } } } output.commentAndLog("Session completed. Exiting process."); return { content: "", nextCommandResponse: { nextCommandAction: NextCommandAction.SessionComplete, wait: noWait(), }, }; } const registrableCommand = { command: sessionCmd, handleCommand, }; function getResumeCommands() { const commands = []; if (restoreInfo) { commands.push("ns-session restore"); } if (resumeWaitSeconds && resumeWaitSeconds > 0) { commands.push(`ns-session continue-wait ${resumeWaitSeconds}`); resumeWaitSeconds = undefined; } return commands; } /** Called by the command handler after each successful dispatch to * maintain the `canComplete` gate. Waits and ns-comment preserve the * flag so `chat send → wait → complete` or `chat send → ns-comment → * complete` is allowed; any other command clears it. */ function updateCanComplete(commandName, cmdArgs) { const sub = stringArgv(cmdArgs)[0]; if (commandName === sessionCmd.name) { // wait/continue-wait preserve the flag; complete is a read, not a // write; other ns-session subs (compact, restore, clear) are real // commands and should invalidate the prior chat/mail. if (sub === "wait" || sub === "continue-wait" || sub === "complete") { return; } canComplete = false; return; } // ns-comment is thinking-out-loud, not a real action — preserve the // gate so the agent can narrate between a send and complete. if (commandName === commentCmd.name) { return; } if ((commandName === chatCmd.name || commandName === mailCmd.name) && sub === "send") { canComplete = true; return; } canComplete = false; } return { ...registrableCommand, getResumeCommands, replayRestoreEntries, hasPendingRestoreEntries, updateCanComplete, }; } /** Prompt-boundary markers (startPrompt/endPrompt) coerce to their content * role so the replay keeps the user/assistant text they carry. */ function sourceFromString(s) { switch (s) { case "console": case "startPrompt": return ContentSource.Console; case "llm": case "endPrompt": return ContentSource.LLM; default: return undefined; } } //# sourceMappingURL=session.js.map