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