This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

niri / src / runner / loop.ts
9.0 kB 236 lines
1import type OpenAI from "openai" 2import { recordMetric } from "../metrics.js" 3import { emit } from "../stream.js" 4import { 5 CONTEXT_COMPACT_TRIGGER_TOKENS, 6 ENABLE_THINKING, 7 FALLBACK_TOKEN_NUDGE_THRESHOLD, 8 TOKEN_NUDGE_THRESHOLD, 9 USE_FALLBACK, 10 estimatePromptTokens, 11 findSummaryMessageIndex, 12 summarizeConversationViaLLM, 13} from "./util.js" 14import { addAssistantMessage, applyUsage, configuredSummaryProvider, emitThinking, fetchCompletion } from "./loop-completion.js" 15import { isFunctionToolCall } from "./loop-content.js" 16import { buildTurnSignature, hasIncomingUserMessage } from "./loop-signatures.js" 17import { processToolCalls } from "./loop-tools.js" 18import type { LoopHooks, LoopState } from "./types.js" 19 20const LLM_POST_TURN_RECENT_MESSAGES = 40 21const RUNNER_MAX_TURNS = parsePositiveIntEnv(process.env.RUNNER_MAX_TURNS, 120) 22const RUNNER_MAX_IDENTICAL_TOOL_TURNS = parsePositiveIntEnv(process.env.RUNNER_MAX_IDENTICAL_TOOL_TURNS, 10) 23 24function parsePositiveIntEnv(value: string | undefined, fallback: number): number { 25 const parsed = Number.parseInt(value ?? "", 10) 26 if (!Number.isFinite(parsed) || parsed < 1) return fallback 27 return parsed 28} 29 30enum CycleOutcome { 31 NoTools = "no_tools", 32 ToolsDone = "tools_done", 33 Rest = "rest", 34} 35 36export type RunLoopExit = "rest" | "guard_stop" | "silent_complete" 37 38async function stopLoopForGuard(state: LoopState, hooks: LoopHooks, reason: string): Promise<RunLoopExit> { 39 const guardMessage = `[system] safety stop: ${reason}. pausing until a new external event wakes niri again.` 40 console.warn(`[runner] ${reason}`) 41 state.conversation.push({ 42 role: "user", 43 content: guardMessage, 44 }) 45 emit({ type: "text", text: guardMessage }) 46 await hooks.saveSession() 47 return "guard_stop" 48} 49 50async function processAssistantTurn(convId: number, state: LoopState, hooks: LoopHooks): Promise<CycleOutcome> { 51 state.memoryRecallTurn += 1 52 const response = await fetchCompletion(state) 53 applyUsage(state, response.usage) 54 55 const msg = response.message 56 addAssistantMessage(convId, state, msg) 57 58 if (ENABLE_THINKING) { 59 if (!response.emittedThinking && response.bufferedThinking) { 60 emit({ type: "thinking", text: response.bufferedThinking }) 61 } else if (!response.emittedThinking) { 62 emitThinking(msg) 63 } 64 } 65 66 const functionCalls = (msg.tool_calls ?? []).filter(isFunctionToolCall) 67 if (!response.emittedText && msg.content) emit({ type: "text", text: msg.content }) 68 if (functionCalls.length === 0) return CycleOutcome.NoTools 69 70 const shouldRest = await processToolCalls(convId, state, hooks, functionCalls) 71 return shouldRest ? CycleOutcome.Rest : CycleOutcome.ToolsDone 72} 73 74/** 75 * Detects when the assistant responded to a Discord message with 76 * conversational text but did not call discord_send. Injects a system 77 * nudge so the next turn actually delivers the message. 78 */ 79function applyDiscordSendNudge(state: LoopState, turnMessages: OpenAI.Chat.ChatCompletionMessage[]): void { 80 // Check if any incoming user message in this turn came from Discord 81 const hasDiscordInput = turnMessages.some( 82 (m) => m.role === "user" && typeof m.content === "string" && /\[discord\/(?:dm|batch|channel)\]/i.test(m.content), 83 ) 84 if (!hasDiscordInput) return 85 86 // Check if the assistant called discord_send in this turn 87 const hasDiscordSend = turnMessages.some( 88 (m) => 89 m.role === "assistant" && 90 Array.isArray(m.tool_calls) && 91 m.tool_calls.some((tc) => tc.type === "function" && tc.function.name === "discord_send"), 92 ) 93 if (hasDiscordSend) return 94 95 // Also check if a tool result from discord_send exists (edge case: tool result is separate message) 96 const hasDiscordSendResult = turnMessages.some( 97 (m) => 98 m.role === "tool" && 99 typeof m.content === "string" && 100 m.content.includes('"ok":true') && 101 m.content.includes("discord_send"), 102 ) 103 if (hasDiscordSendResult) return 104 105 // Find the assistant's text content in this turn 106 const assistantText = turnMessages.find( 107 (m) => m.role === "assistant" && typeof m.content === "string" && m.content.trim().length > 0, 108 ) 109 if (!assistantText || typeof assistantText !== "object") return 110 111 // The assistant wrote something in response to a Discord message but 112 // never actually sent it. Nudge. 113 const nudge = `[system] you wrote a response to a Discord message but did not call discord_send. your message was not delivered. call discord_send now or explicitly decide not to reply.` 114 console.warn("[runner] discord_send nudge: assistant responded to Discord input without calling discord_send") 115 state.conversation.push({ role: "user", content: nudge }) 116} 117 118function applyContextNudge(state: LoopState): void { 119 const tokenNudgeThreshold = USE_FALLBACK ? FALLBACK_TOKEN_NUDGE_THRESHOLD : TOKEN_NUDGE_THRESHOLD 120 const contextProvider = USE_FALLBACK ? "fallback" : "primary" 121 122 if (state.contextSize >= tokenNudgeThreshold) { 123 state.conversation.push({ 124 role: "user", 125 content: `[system] context at ~${Math.round(state.contextSize / 1000)}k tokens (${contextProvider}). Consider wrapping up soon to stay within the context limit.`, 126 }) 127 } 128} 129 130async function applyLLMCompaction(state: LoopState, phase: "pre-turn" | "post-turn"): Promise<boolean> { 131 const beforeEstimate = estimatePromptTokens(state.conversation) 132 const contextPressure = Math.max(state.contextSize, beforeEstimate) 133 if (contextPressure < CONTEXT_COMPACT_TRIGGER_TOKENS) return false 134 135 const summaryProvider = configuredSummaryProvider() 136 if (!summaryProvider.client || !summaryProvider.model) { 137 console.warn(`[context] ${phase}: no summary client available; skipping llm compaction`) 138 return false 139 } 140 141 const beforeCount = state.conversation.length 142 const summarized = await summarizeConversationViaLLM( 143 state.conversation, 144 summaryProvider.client, 145 summaryProvider.model, 146 { recentKeep: LLM_POST_TURN_RECENT_MESSAGES }, 147 ) 148 if (!summarized) { 149 console.warn(`[context] ${phase}: llm summary unavailable; keeping raw conversation`) 150 return false 151 } 152 153 const afterEstimate = estimatePromptTokens(summarized) 154 if (afterEstimate >= beforeEstimate) { 155 console.warn(`[context] ${phase}: llm summary not smaller (${beforeEstimate} -> ${afterEstimate}); keeping raw conversation`) 156 return false 157 } 158 159 state.conversation = summarized 160 state.contextSize = afterEstimate 161 162 const summaryIdx = findSummaryMessageIndex(state.conversation) 163 const summary = summaryIdx >= 0 ? (state.conversation[summaryIdx]?.content as string) : undefined 164 165 console.log( 166 `[context] ${phase}: llm-summarized conversation via ${summaryProvider.model} (${beforeCount} -> ${summarized.length} msgs, ${beforeEstimate} -> ${afterEstimate} tokens)`, 167 ) 168 169 recordMetric({ 170 type: "compaction", 171 before: beforeEstimate, 172 after: afterEstimate, 173 method: `${phase}-llm`, 174 summary, 175 }) 176 return true 177} 178 179export async function runLoop(convId: number, state: LoopState, hooks: LoopHooks): Promise<RunLoopExit> { 180 let turnCount = 0 181 let previousTurnSignature: string | null = null 182 let consecutiveIdenticalToolTurns = 0 183 184 while (true) { 185 const preCompacted = await applyLLMCompaction(state, "pre-turn") 186 if (preCompacted) await hooks.saveSession() 187 188 const turnStart = state.conversation.length 189 const outcome = await processAssistantTurn(convId, state, hooks) 190 turnCount += 1 191 192 const turnMessages = state.conversation.slice(turnStart) 193 const interruptedByUserEvent = hasIncomingUserMessage(turnMessages) 194 const turnSignature = buildTurnSignature(turnMessages) 195 196 // Nudge when the assistant produces conversational text in response to 197 // a Discord message but forgets to call discord_send. This is a common 198 // hallucination pattern — the model writes a reply "in its head" and 199 // then calls wait/rest, leaving the Discord user in silence. 200 if (outcome !== CycleOutcome.Rest) { 201 applyDiscordSendNudge(state, turnMessages) 202 } 203 204 if (interruptedByUserEvent || !turnSignature) { 205 previousTurnSignature = null 206 consecutiveIdenticalToolTurns = 0 207 } else if (turnSignature === previousTurnSignature) { 208 consecutiveIdenticalToolTurns += 1 209 } else { 210 previousTurnSignature = turnSignature 211 consecutiveIdenticalToolTurns = 1 212 } 213 214 if (outcome === CycleOutcome.Rest) return "rest" 215 if (outcome === CycleOutcome.NoTools) { 216 await hooks.saveSession() 217 return "silent_complete" 218 } 219 220 if (turnCount >= RUNNER_MAX_TURNS) { 221 return stopLoopForGuard(state, hooks, `loop guard tripped after ${turnCount} turns`) 222 } 223 224 if (consecutiveIdenticalToolTurns >= RUNNER_MAX_IDENTICAL_TOOL_TURNS && previousTurnSignature) { 225 return stopLoopForGuard( 226 state, 227 hooks, 228 `loop guard tripped after ${consecutiveIdenticalToolTurns} identical assistant/tool turns`, 229 ) 230 } 231 232 await applyLLMCompaction(state, "post-turn") 233 applyContextNudge(state) 234 await hooks.saveSession() 235 } 236}