my harness for niri
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 286 lines 11 kB view raw
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}