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 { assistantContentText, 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"
37
38async function waitForNextEvent(convId: number, hooks: LoopHooks): Promise<void> {
39 const incoming = await hooks.waitForEvent()
40 hooks.injectIncomingEvent(convId, incoming)
41}
42
43async function stopLoopForGuard(state: LoopState, hooks: LoopHooks, reason: string): Promise<RunLoopExit> {
44 const guardMessage =
45 `[system] hey, you've been going for a while (${reason}). are you stuck and need to tell who you're talking to there's a problem? or is nothing happening and maybe it's time for a rest? remember to tell your important people you are resting if you are going to.`
46 console.warn(`[runner] ${reason}`)
47 state.conversation.push({
48 role: "user",
49 content: guardMessage,
50 })
51 emit({ type: "text", text: guardMessage })
52 await hooks.saveSession()
53 return "guard_stop"
54}
55
56async function processAssistantTurn(convId: number, state: LoopState, hooks: LoopHooks): Promise<CycleOutcome> {
57 state.memoryRecallTurn += 1
58 const response = await fetchCompletion(state)
59 applyUsage(state, response.usage)
60
61 const msg = response.message
62 addAssistantMessage(convId, state, msg)
63
64 if (ENABLE_THINKING) {
65 if (!response.emittedThinking && response.bufferedThinking) {
66 emit({ type: "thinking", text: response.bufferedThinking })
67 } else if (!response.emittedThinking) {
68 emitThinking(msg)
69 }
70 }
71
72 const functionCalls = (msg.tool_calls ?? []).filter(isFunctionToolCall)
73 if (!response.emittedText && msg.content) emit({ type: "text", text: msg.content })
74 if (functionCalls.length === 0) return CycleOutcome.NoTools
75
76 const shouldRest = await processToolCalls(convId, state, hooks, functionCalls)
77 return shouldRest ? CycleOutcome.Rest : CycleOutcome.ToolsDone
78}
79
80/**
81 * Detects when the assistant responded to a Discord message with
82 * conversational text but did not call discord_send. Injects a system
83 * nudge so the next turn actually delivers the message.
84 */
85function isDiscordInputMessage(message: OpenAI.Chat.ChatCompletionMessage | OpenAI.Chat.ChatCompletionMessageParam): boolean {
86 return message.role === "user" && typeof message.content === "string" && /\[discord\/(?:dm|batch|channel)\]/i.test(message.content)
87}
88
89function hasDiscordInputForTurn(
90 conversation: OpenAI.Chat.ChatCompletionMessageParam[],
91 turnMessages: OpenAI.Chat.ChatCompletionMessage[],
92 turnStart: number,
93): boolean {
94 if (turnMessages.some(isDiscordInputMessage)) return true
95
96 // After a harness restart, the triggering Discord event is appended before
97 // the first post-restart assistant turn. Look backward to the latest
98 // assistant boundary and treat intervening user messages as active context.
99 for (let i = turnStart - 1; i >= 0; i--) {
100 const message = conversation[i]
101 if (!message) continue
102 if (message.role === "assistant") break
103 if (isDiscordInputMessage(message)) return true
104 }
105
106 return false
107}
108
109function applyDiscordSendNudge(
110 state: LoopState,
111 turnMessages: OpenAI.Chat.ChatCompletionMessage[],
112 turnStart: number,
113): boolean {
114 // Check if the assistant is responding to active Discord input, including
115 // the post-restart case where the triggering user message is already in the
116 // conversation before the turn begins.
117 const hasDiscordInput = hasDiscordInputForTurn(state.conversation, turnMessages, turnStart)
118 if (!hasDiscordInput) return false
119
120 // Check if the assistant called discord_send in this turn
121 const hasDiscordSend = turnMessages.some(
122 (m) =>
123 m.role === "assistant" &&
124 Array.isArray(m.tool_calls) &&
125 m.tool_calls.some((tc) => tc.type === "function" && tc.function.name === "discord_send"),
126 )
127 if (hasDiscordSend) return false
128
129 // Also check if a tool result from discord_send exists (edge case: tool result is separate message)
130 const hasDiscordSendResult = turnMessages.some(
131 (m) =>
132 m.role === "tool" &&
133 typeof m.content === "string" &&
134 m.content.includes('"ok":true') &&
135 m.content.includes("discord_send"),
136 )
137 if (hasDiscordSendResult) return false
138
139 // Find the assistant's text content in this turn
140 const assistantText = turnMessages.find((m) => m.role === "assistant" && assistantContentText(m.content).length > 0)
141 if (!assistantText) return false
142
143 // The assistant wrote something in response to a Discord message but
144 // never actually sent it. Nudge.
145 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.`
146 console.warn("[runner] discord_send nudge: assistant responded to Discord input without calling discord_send")
147 state.conversation.push({ role: "user", content: nudge })
148 return true
149}
150
151function applyContextNudge(state: LoopState): void {
152 const tokenNudgeThreshold = USE_FALLBACK ? FALLBACK_TOKEN_NUDGE_THRESHOLD : TOKEN_NUDGE_THRESHOLD
153 const contextProvider = USE_FALLBACK ? "fallback" : "primary"
154
155 if (state.contextSize >= tokenNudgeThreshold) {
156 state.conversation.push({
157 role: "user",
158 content: `[system] context at ~${Math.round(state.contextSize / 1000)}k tokens (${contextProvider}). Consider wrapping up soon to stay within the context limit.`,
159 })
160 }
161}
162
163async function applyLLMCompaction(state: LoopState, phase: "pre-turn" | "post-turn"): Promise<boolean> {
164 const beforeEstimate = estimatePromptTokens(state.conversation)
165 const contextPressure = Math.max(state.contextSize, beforeEstimate)
166 if (contextPressure < CONTEXT_COMPACT_TRIGGER_TOKENS) return false
167
168 const summaryProvider = configuredSummaryProvider()
169 if (!summaryProvider.client || !summaryProvider.model) {
170 console.warn(`[context] ${phase}: no summary client available; skipping llm compaction`)
171 return false
172 }
173
174 const beforeCount = state.conversation.length
175 const summarized = await summarizeConversationViaLLM(
176 state.conversation,
177 summaryProvider.client,
178 summaryProvider.model,
179 { recentKeep: LLM_POST_TURN_RECENT_MESSAGES },
180 )
181 if (!summarized) {
182 console.warn(`[context] ${phase}: llm summary unavailable; keeping raw conversation`)
183 return false
184 }
185
186 const afterEstimate = estimatePromptTokens(summarized)
187 if (afterEstimate >= beforeEstimate) {
188 console.warn(`[context] ${phase}: llm summary not smaller (${beforeEstimate} -> ${afterEstimate}); keeping raw conversation`)
189 return false
190 }
191
192 state.conversation = summarized
193 state.contextSize = afterEstimate
194
195 const summaryIdx = findSummaryMessageIndex(state.conversation)
196 const summary = summaryIdx >= 0 ? (state.conversation[summaryIdx]?.content as string) : undefined
197
198 console.log(
199 `[context] ${phase}: llm-summarized conversation via ${summaryProvider.model} (${beforeCount} -> ${summarized.length} msgs, ${beforeEstimate} -> ${afterEstimate} tokens)`,
200 )
201
202 recordMetric({
203 type: "compaction",
204 before: beforeEstimate,
205 after: afterEstimate,
206 method: `${phase}-llm`,
207 summary,
208 })
209 return true
210}
211
212export async function runLoop(convId: number, state: LoopState, hooks: LoopHooks): Promise<RunLoopExit> {
213 let turnCount = 0
214 let previousTurnSignature: string | null = null
215 let consecutiveIdenticalToolTurns = 0
216
217 while (true) {
218 const preCompacted = await applyLLMCompaction(state, "pre-turn")
219 if (preCompacted) await hooks.saveSession()
220
221 const turnStart = state.conversation.length
222 const outcome = await processAssistantTurn(convId, state, hooks)
223 turnCount += 1
224
225 const turnMessages = state.conversation.slice(turnStart)
226 const interruptedByUserEvent = hasIncomingUserMessage(turnMessages)
227 const turnSignature = buildTurnSignature(turnMessages)
228
229 // Nudge when the assistant produces conversational text in response to
230 // a Discord message but forgets to call discord_send. This is a common
231 // hallucination pattern — the model writes a reply "in its head" and
232 // then calls wait/rest, leaving the Discord user in silence.
233 let discordSendNudged = false
234 if (outcome !== CycleOutcome.Rest) {
235 discordSendNudged = applyDiscordSendNudge(state, turnMessages, turnStart)
236 }
237
238 if (interruptedByUserEvent || !turnSignature) {
239 previousTurnSignature = null
240 consecutiveIdenticalToolTurns = 0
241 } else if (turnSignature === previousTurnSignature) {
242 consecutiveIdenticalToolTurns += 1
243 } else {
244 previousTurnSignature = turnSignature
245 consecutiveIdenticalToolTurns = 1
246 }
247
248 if (outcome === CycleOutcome.Rest) return "rest"
249 if (outcome === CycleOutcome.NoTools) {
250 if (discordSendNudged) continue
251 await hooks.saveSession()
252 await waitForNextEvent(convId, hooks)
253 continue
254 }
255
256 if (turnCount >= RUNNER_MAX_TURNS) {
257 return stopLoopForGuard(state, hooks, `loop guard tripped after ${turnCount} turns`)
258 }
259
260 if (consecutiveIdenticalToolTurns >= RUNNER_MAX_IDENTICAL_TOOL_TURNS && previousTurnSignature) {
261 return stopLoopForGuard(
262 state,
263 hooks,
264 `loop guard tripped after ${consecutiveIdenticalToolTurns} identical assistant/tool turns`,
265 )
266 }
267
268 await applyLLMCompaction(state, "post-turn")
269 applyContextNudge(state)
270 await hooks.saveSession()
271 }
272}
273
274export const __loopTest = {
275 applyDiscordSendNudge,
276 hasDiscordInputForTurn,
277 waitForNextEvent,
278}