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
12 kB 303 lines
1import { recordMetric } from "../metrics" 2import { emit } from "../stream" 3import type { Message } from "../types" 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 loadAgentSummaryContext, 13 summarizeConversationViaLLM, 14} from "./util" 15import { addAssistantMessage, applyUsage, configuredSummaryProvider, emitThinking, fetchCompletion } from "./loop-completion" 16import { assistantContentText, isFunctionToolCall } from "./loop-content" 17import { buildTurnSignature, hasIncomingUserMessage } from "./loop-signatures" 18import { processToolCalls } from "./loop-tools" 19import type { LoopHooks, LoopState } from "./types" 20 21const LLM_RECENT_MIN_KEEP = 6 22const LLM_RECENT_MAX_KEEP = 40 23const LLM_TAIL_CHAR_BUDGET = 60_000 24const RUNNER_MAX_TURNS = parsePositiveIntEnv(process.env.RUNNER_MAX_TURNS, 120) 25const RUNNER_MAX_IDENTICAL_TOOL_TURNS = parsePositiveIntEnv(process.env.RUNNER_MAX_IDENTICAL_TOOL_TURNS, 10) 26 27function parsePositiveIntEnv(value: string | undefined, fallback: number): number { 28 const parsed = Number.parseInt(value ?? "", 10) 29 if (!Number.isFinite(parsed) || parsed < 1) return fallback 30 return parsed 31} 32 33enum CycleOutcome { 34 NoTools = "no_tools", 35 ToolsDone = "tools_done", 36 Rest = "rest", 37} 38 39export type RunLoopExit = "rest" 40 41async function waitForNextEvent(convId: number, hooks: LoopHooks): Promise<void> { 42 const incoming = await hooks.waitForEvent() 43 hooks.injectIncomingEvent(convId, incoming) 44} 45 46async function applyLoopGuardNudge(state: LoopState, hooks: LoopHooks, reason: string): Promise<void> { 47 const guardMessage = 48 `[system] hey, you've been going for a while (${reason}). this is just a heads-up in case you're stuck and need help — most likely you're fine and don't need to do anything different, especially if you're actively doing something, in conversation with people, or things are happening RIGHT NOW. just keep going. resting is only a suggestion for if nothing's actually happening and you're genuinely done; if you do rest, remember to tell your important people first.` 49 console.warn(`[runner] ${reason}`) 50 state.conversation.push({ 51 role: "user", 52 content: guardMessage, 53 }) 54 emit({ type: "text", text: guardMessage }) 55 await hooks.saveSession() 56} 57 58async function processAssistantTurn(convId: number, state: LoopState, hooks: LoopHooks): Promise<CycleOutcome> { 59 state.memoryRecallTurn += 1 60 const response = await fetchCompletion(state) 61 // Recall (if any) has been applied for this turn; don't re-recall on the 62 // follow-up iterations that work through the same incoming event. 63 state.memoryRecallPending = false 64 applyUsage(state, response.usage, { 65 elapsedMs: response.elapsedMs, 66 tokensPerSecond: response.tokensPerSecond, 67 }) 68 69 const msg = response.message 70 addAssistantMessage(convId, state, msg) 71 72 if (ENABLE_THINKING) { 73 if (!response.emittedThinking && response.bufferedThinking) { 74 emit({ type: "thinking", text: response.bufferedThinking }) 75 } else if (!response.emittedThinking) { 76 emitThinking(msg) 77 } 78 } 79 80 const functionCalls = (msg.tool_calls ?? []).filter(isFunctionToolCall) 81 if (!response.emittedText && msg.content) emit({ type: "text", text: msg.content }) 82 if (functionCalls.length === 0) return CycleOutcome.NoTools 83 84 const shouldRest = await processToolCalls(convId, state, hooks, functionCalls) 85 return shouldRest ? CycleOutcome.Rest : CycleOutcome.ToolsDone 86} 87 88/** 89 * Detects when the assistant responded to a Discord message with 90 * conversational text but did not call discord_send. Injects a system 91 * nudge so the next turn actually delivers the message. 92 */ 93function isDiscordInputMessage(message: Message): boolean { 94 return message.role === "user" && typeof message.content === "string" && /\[discord(?:\/(?:dm|channel)| batch)\]/i.test(message.content) 95} 96 97function hasDiscordInputForTurn( 98 conversation: Message[], 99 turnMessages: Message[], 100 turnStart: number, 101): boolean { 102 if (turnMessages.some(isDiscordInputMessage)) return true 103 104 // After a harness restart, the triggering Discord event is appended before 105 // the first post-restart assistant turn. Look backward to the latest 106 // assistant boundary and treat intervening user messages as active context. 107 for (let i = turnStart - 1; i >= 0; i--) { 108 const message = conversation[i] 109 if (!message) continue 110 if (message.role === "assistant") break 111 if (isDiscordInputMessage(message)) return true 112 } 113 114 return false 115} 116 117function applyDiscordSendNudge( 118 state: LoopState, 119 turnMessages: Message[], 120 turnStart = state.conversation.length, 121): boolean { 122 // Check if the assistant is responding to active Discord input, including 123 // the post-restart case where the triggering user message is already in the 124 // conversation before the turn begins. 125 const hasDiscordInput = hasDiscordInputForTurn(state.conversation, turnMessages, turnStart) 126 if (!hasDiscordInput) return false 127 128 // Check if the assistant called discord_send in this turn 129 const hasDiscordSend = turnMessages.some( 130 (m) => 131 m.role === "assistant" && 132 "tool_calls" in m && 133 Array.isArray(m.tool_calls) && 134 m.tool_calls.some((tc) => tc.type === "function" && tc.function.name === "discord_send"), 135 ) 136 if (hasDiscordSend) return false 137 138 // Also check if a tool result from discord_send exists (edge case: tool result is separate message) 139 const hasDiscordSendResult = turnMessages.some( 140 (m) => 141 m.role === "tool" && 142 typeof m.content === "string" && 143 m.content.includes('"ok":true') && 144 m.content.includes("discord_send"), 145 ) 146 if (hasDiscordSendResult) return false 147 148 // Find the assistant's text content in this turn 149 const assistantText = turnMessages.find((m) => m.role === "assistant" && assistantContentText(m.content).length > 0) 150 if (!assistantText) return false 151 152 // The assistant wrote something in response to a Discord message but 153 // never actually sent it. Nudge. 154 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.` 155 console.warn("[runner] discord_send nudge: assistant responded to Discord input without calling discord_send") 156 state.conversation.push({ role: "user", content: nudge }) 157 return true 158} 159 160function applyContextNudge(state: LoopState): void { 161 const tokenNudgeThreshold = USE_FALLBACK ? FALLBACK_TOKEN_NUDGE_THRESHOLD : TOKEN_NUDGE_THRESHOLD 162 const contextProvider = USE_FALLBACK ? "fallback" : "primary" 163 164 if (state.contextSize >= tokenNudgeThreshold) { 165 state.conversation.push({ 166 role: "user", 167 content: `[system] context at ~${Math.round(state.contextSize / 1000)}k tokens (${contextProvider}). Consider wrapping up soon to stay within the context limit.`, 168 }) 169 } 170} 171 172async function applyLLMCompaction(state: LoopState, phase: "pre-turn" | "post-turn"): Promise<boolean> { 173 // Gate strictly on the model-reported prompt_tokens (state.contextSize). 174 // The char-based estimatePromptTokens inflates the tools schema ~3×, which 175 // used to fire compaction at ~22-31k real tokens and produce nonsense summaries. 176 if (state.contextSize < CONTEXT_COMPACT_TRIGGER_TOKENS) return false 177 const beforeEstimate = estimatePromptTokens(state.conversation) 178 179 const summaryProvider = await configuredSummaryProvider() 180 if (!summaryProvider.client || !summaryProvider.model) { 181 console.warn(`[context] ${phase}: no summary client available; skipping llm compaction`) 182 return false 183 } 184 185 const beforeCount = state.conversation.length 186 const agentContext = await loadAgentSummaryContext() 187 const summarized = await summarizeConversationViaLLM( 188 state.conversation, 189 summaryProvider.client, 190 summaryProvider.model, 191 { 192 recentMinKeep: LLM_RECENT_MIN_KEEP, 193 recentMaxKeep: LLM_RECENT_MAX_KEEP, 194 tailCharBudget: LLM_TAIL_CHAR_BUDGET, 195 agentContext, 196 }, 197 ) 198 if (!summarized) { 199 console.warn(`[context] ${phase}: llm summary unavailable; keeping raw conversation`) 200 return false 201 } 202 203 const afterEstimate = estimatePromptTokens(summarized) 204 if (afterEstimate >= beforeEstimate) { 205 console.warn(`[context] ${phase}: llm summary not smaller (${beforeEstimate} -> ${afterEstimate}); keeping raw conversation`) 206 return false 207 } 208 209 state.conversation = summarized 210 state.contextSize = afterEstimate 211 212 const summaryIdx = findSummaryMessageIndex(state.conversation) 213 const summary = summaryIdx >= 0 ? (state.conversation[summaryIdx]?.content as string) : undefined 214 215 console.log( 216 `[context] ${phase}: llm-summarized conversation via ${summaryProvider.model} (${beforeCount} -> ${summarized.length} msgs, ${beforeEstimate} -> ${afterEstimate} tokens)`, 217 ) 218 219 recordMetric({ 220 type: "compaction", 221 before: beforeEstimate, 222 after: afterEstimate, 223 method: `${phase}-llm`, 224 summary, 225 }) 226 return true 227} 228 229export async function runLoop(convId: number, state: LoopState, hooks: LoopHooks): Promise<RunLoopExit> { 230 let turnCount = 0 231 let previousTurnSignature: string | null = null 232 let consecutiveIdenticalToolTurns = 0 233 234 while (true) { 235 const preCompacted = await applyLLMCompaction(state, "pre-turn") 236 if (preCompacted) await hooks.saveSession() 237 238 const turnStart = state.conversation.length 239 const outcome = await processAssistantTurn(convId, state, hooks) 240 turnCount += 1 241 242 const turnMessages = state.conversation.slice(turnStart) 243 const interruptedByUserEvent = hasIncomingUserMessage(turnMessages) 244 const turnSignature = buildTurnSignature(turnMessages) 245 246 // Nudge when the assistant produces conversational text in response to 247 // a Discord message but forgets to call discord_send. This is a common 248 // hallucination pattern — the model writes a reply "in its head" and 249 // then calls wait/rest, leaving the Discord user in silence. 250 let discordSendNudged = false 251 if (outcome !== CycleOutcome.Rest) { 252 discordSendNudged = applyDiscordSendNudge(state, turnMessages, turnStart) 253 } 254 255 if (interruptedByUserEvent || !turnSignature) { 256 previousTurnSignature = null 257 consecutiveIdenticalToolTurns = 0 258 } else if (turnSignature === previousTurnSignature) { 259 consecutiveIdenticalToolTurns += 1 260 } else { 261 previousTurnSignature = turnSignature 262 consecutiveIdenticalToolTurns = 1 263 } 264 265 if (outcome === CycleOutcome.Rest) return "rest" 266 if (outcome === CycleOutcome.NoTools) { 267 if (discordSendNudged) continue 268 await hooks.saveSession() 269 await waitForNextEvent(convId, hooks) 270 continue 271 } 272 273 if (turnCount >= RUNNER_MAX_TURNS) { 274 await applyLoopGuardNudge(state, hooks, `loop guard tripped after ${turnCount} turns`) 275 turnCount = 0 276 previousTurnSignature = null 277 consecutiveIdenticalToolTurns = 0 278 continue 279 } 280 281 if (consecutiveIdenticalToolTurns >= RUNNER_MAX_IDENTICAL_TOOL_TURNS && previousTurnSignature) { 282 await applyLoopGuardNudge( 283 state, 284 hooks, 285 `loop guard tripped after ${consecutiveIdenticalToolTurns} identical assistant/tool turns`, 286 ) 287 previousTurnSignature = null 288 consecutiveIdenticalToolTurns = 0 289 continue 290 } 291 292 await applyLLMCompaction(state, "post-turn") 293 applyContextNudge(state) 294 await hooks.saveSession() 295 } 296} 297 298export const __loopTest = { 299 applyLoopGuardNudge, 300 applyDiscordSendNudge, 301 hasDiscordInputForTurn, 302 waitForNextEvent, 303}