my harness for niri
1import { recordMetric } from "../metrics.js"
2import { emit } from "../stream.js"
3import type { Message } from "../types.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"
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 applyLoopGuardNudge(state: LoopState, hooks: LoopHooks, reason: string): Promise<void> {
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}
54
55async function processAssistantTurn(convId: number, state: LoopState, hooks: LoopHooks): Promise<CycleOutcome> {
56 state.memoryRecallTurn += 1
57 const response = await fetchCompletion(state)
58 applyUsage(state, response.usage)
59
60 const msg = response.message
61 addAssistantMessage(convId, state, msg)
62
63 if (ENABLE_THINKING) {
64 if (!response.emittedThinking && response.bufferedThinking) {
65 emit({ type: "thinking", text: response.bufferedThinking })
66 } else if (!response.emittedThinking) {
67 emitThinking(msg)
68 }
69 }
70
71 const functionCalls = (msg.tool_calls ?? []).filter(isFunctionToolCall)
72 if (!response.emittedText && msg.content) emit({ type: "text", text: msg.content })
73 if (functionCalls.length === 0) return CycleOutcome.NoTools
74
75 const shouldRest = await processToolCalls(convId, state, hooks, functionCalls)
76 return shouldRest ? CycleOutcome.Rest : CycleOutcome.ToolsDone
77}
78
79/**
80 * Detects when the assistant responded to a Discord message with
81 * conversational text but did not call discord_send. Injects a system
82 * nudge so the next turn actually delivers the message.
83 */
84function isDiscordInputMessage(message: Message): boolean {
85 return message.role === "user" && typeof message.content === "string" && /\[discord\/(?:dm|batch|channel)\]/i.test(message.content)
86}
87
88function hasDiscordInputForTurn(
89 conversation: Message[],
90 turnMessages: Message[],
91 turnStart: number,
92): boolean {
93 if (turnMessages.some(isDiscordInputMessage)) return true
94
95 // After a harness restart, the triggering Discord event is appended before
96 // the first post-restart assistant turn. Look backward to the latest
97 // assistant boundary and treat intervening user messages as active context.
98 for (let i = turnStart - 1; i >= 0; i--) {
99 const message = conversation[i]
100 if (!message) continue
101 if (message.role === "assistant") break
102 if (isDiscordInputMessage(message)) return true
103 }
104
105 return false
106}
107
108function applyDiscordSendNudge(
109 state: LoopState,
110 turnMessages: Message[],
111 turnStart = state.conversation.length,
112): boolean {
113 // Check if the assistant is responding to active Discord input, including
114 // the post-restart case where the triggering user message is already in the
115 // conversation before the turn begins.
116 const hasDiscordInput = hasDiscordInputForTurn(state.conversation, turnMessages, turnStart)
117 if (!hasDiscordInput) return false
118
119 // Check if the assistant called discord_send in this turn
120 const hasDiscordSend = turnMessages.some(
121 (m) =>
122 m.role === "assistant" &&
123 "tool_calls" in m &&
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 await applyLoopGuardNudge(state, hooks, `loop guard tripped after ${turnCount} turns`)
258 turnCount = 0
259 previousTurnSignature = null
260 consecutiveIdenticalToolTurns = 0
261 continue
262 }
263
264 if (consecutiveIdenticalToolTurns >= RUNNER_MAX_IDENTICAL_TOOL_TURNS && previousTurnSignature) {
265 await applyLoopGuardNudge(
266 state,
267 hooks,
268 `loop guard tripped after ${consecutiveIdenticalToolTurns} identical assistant/tool turns`,
269 )
270 previousTurnSignature = null
271 consecutiveIdenticalToolTurns = 0
272 continue
273 }
274
275 await applyLLMCompaction(state, "post-turn")
276 applyContextNudge(state)
277 await hooks.saveSession()
278 }
279}
280
281export const __loopTest = {
282 applyLoopGuardNudge,
283 applyDiscordSendNudge,
284 hasDiscordInputForTurn,
285 waitForNextEvent,
286}