This repository has no description
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}