my harness for niri
1
fork

Configure Feed

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

loop guard gone

+48 -12
+1 -5
src/runner/index.ts
··· 243 243 clearSession, 244 244 saveSession: async () => saveSession(state.conversation), 245 245 }) 246 - if (exit === "rest") { 247 - setRunnerPresence("resting") 248 - } else { 249 - console.warn("[runner] loop guard stopped the run; waiting for a new external event") 250 - } 246 + if (exit === "rest") setRunnerPresence("resting") 251 247 } catch (err) { 252 248 const message = err instanceof Error ? err.message : String(err) 253 249 console.error(`[runner] loop aborted: ${message}`)
+29 -1
src/runner/loop.test.ts
··· 1 1 import assert from "node:assert/strict" 2 2 import test from "node:test" 3 3 import { __loopTest } from "./loop.js" 4 + import type { Message } from "../types.js" 4 5 import type { LoopState } from "./types.js" 5 6 import type { Message } from "../types.js" 6 7 ··· 50 51 ) 51 52 52 53 const turnStart = 1 53 - const turnMessages = [state.conversation[1]!] 54 + const turnMessages: Message[] = [state.conversation[1]!] 54 55 55 56 const nudged = __loopTest.applyDiscordSendNudge(state, turnMessages, turnStart) 56 57 ··· 86 87 87 88 assert.equal(nudged, false) 88 89 assert.equal(state.conversation.length, 0) 90 + }) 91 + 92 + test("applyLoopGuardNudge appends an in-band user nudge and saves", async () => { 93 + const state = makeState() 94 + let saved = false 95 + 96 + await __loopTest.applyLoopGuardNudge( 97 + state, 98 + { 99 + waitForEvent: async () => { 100 + throw new Error("unexpected wait") 101 + }, 102 + waitForEventWithTimeout: async () => null, 103 + injectIncomingEvent: () => {}, 104 + flushDeferredEvents: () => {}, 105 + clearSession: async () => {}, 106 + saveSession: async () => { 107 + saved = true 108 + }, 109 + }, 110 + "loop guard tripped after 120 turns", 111 + ) 112 + 113 + assert.equal(saved, true) 114 + assert.equal(state.conversation.length, 1) 115 + assert.equal(state.conversation[0]?.role, "user") 116 + assert.match(String(state.conversation[0]?.content), /loop guard tripped after 120 turns/i) 89 117 }) 90 118 91 119 test("waitForNextEvent waits for and injects the next external event", async () => {
+13 -5
src/runner/loop.ts
··· 33 33 Rest = "rest", 34 34 } 35 35 36 - export type RunLoopExit = "rest" | "guard_stop" 36 + export type RunLoopExit = "rest" 37 37 38 38 async function waitForNextEvent(convId: number, hooks: LoopHooks): Promise<void> { 39 39 const incoming = await hooks.waitForEvent() 40 40 hooks.injectIncomingEvent(convId, incoming) 41 41 } 42 42 43 - async function stopLoopForGuard(state: LoopState, hooks: LoopHooks, reason: string): Promise<RunLoopExit> { 43 + async function applyLoopGuardNudge(state: LoopState, hooks: LoopHooks, reason: string): Promise<void> { 44 44 const guardMessage = 45 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 46 console.warn(`[runner] ${reason}`) ··· 50 50 }) 51 51 emit({ type: "text", text: guardMessage }) 52 52 await hooks.saveSession() 53 - return "guard_stop" 54 53 } 55 54 56 55 async function processAssistantTurn(convId: number, state: LoopState, hooks: LoopHooks): Promise<CycleOutcome> { ··· 121 120 const hasDiscordSend = turnMessages.some( 122 121 (m) => 123 122 m.role === "assistant" && 123 + "tool_calls" in m && 124 124 Array.isArray(m.tool_calls) && 125 125 m.tool_calls.some((tc) => tc.type === "function" && tc.function.name === "discord_send"), 126 126 ) ··· 254 254 } 255 255 256 256 if (turnCount >= RUNNER_MAX_TURNS) { 257 - return stopLoopForGuard(state, hooks, `loop guard tripped after ${turnCount} turns`) 257 + await applyLoopGuardNudge(state, hooks, `loop guard tripped after ${turnCount} turns`) 258 + turnCount = 0 259 + previousTurnSignature = null 260 + consecutiveIdenticalToolTurns = 0 261 + continue 258 262 } 259 263 260 264 if (consecutiveIdenticalToolTurns >= RUNNER_MAX_IDENTICAL_TOOL_TURNS && previousTurnSignature) { 261 - return stopLoopForGuard( 265 + await applyLoopGuardNudge( 262 266 state, 263 267 hooks, 264 268 `loop guard tripped after ${consecutiveIdenticalToolTurns} identical assistant/tool turns`, 265 269 ) 270 + previousTurnSignature = null 271 + consecutiveIdenticalToolTurns = 0 272 + continue 266 273 } 267 274 268 275 await applyLLMCompaction(state, "post-turn") ··· 272 279 } 273 280 274 281 export const __loopTest = { 282 + applyLoopGuardNudge, 275 283 applyDiscordSendNudge, 276 284 hasDiscordInputForTurn, 277 285 waitForNextEvent,
+5 -1
src/types.ts
··· 1 1 import type OpenAI from "openai" 2 2 3 - export type Message = OpenAI.Chat.ChatCompletionMessageParam 3 + export type AssistantMessageWithReasoning = OpenAI.Chat.ChatCompletionAssistantMessageParam & { 4 + reasoning_content?: string 5 + } 6 + 7 + export type Message = OpenAI.Chat.ChatCompletionMessageParam | AssistantMessageWithReasoning 4 8 5 9 export type TriggerSource = "discord" | "bsky" | "webhook" | "cron" | "chat" 6 10