···
243
243
clearSession,
244
244
saveSession: async () => saveSession(state.conversation),
245
245
})
246
246
-
if (exit === "rest") {
247
247
-
setRunnerPresence("resting")
248
248
-
} else {
249
249
-
console.warn("[runner] loop guard stopped the run; waiting for a new external event")
250
250
-
}
246
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}`)
···
1
1
import assert from "node:assert/strict"
2
2
import test from "node:test"
3
3
import { __loopTest } from "./loop.js"
4
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
53
-
const turnMessages = [state.conversation[1]!]
54
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
90
+
})
91
91
+
92
92
+
test("applyLoopGuardNudge appends an in-band user nudge and saves", async () => {
93
93
+
const state = makeState()
94
94
+
let saved = false
95
95
+
96
96
+
await __loopTest.applyLoopGuardNudge(
97
97
+
state,
98
98
+
{
99
99
+
waitForEvent: async () => {
100
100
+
throw new Error("unexpected wait")
101
101
+
},
102
102
+
waitForEventWithTimeout: async () => null,
103
103
+
injectIncomingEvent: () => {},
104
104
+
flushDeferredEvents: () => {},
105
105
+
clearSession: async () => {},
106
106
+
saveSession: async () => {
107
107
+
saved = true
108
108
+
},
109
109
+
},
110
110
+
"loop guard tripped after 120 turns",
111
111
+
)
112
112
+
113
113
+
assert.equal(saved, true)
114
114
+
assert.equal(state.conversation.length, 1)
115
115
+
assert.equal(state.conversation[0]?.role, "user")
116
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 () => {
···
33
33
Rest = "rest",
34
34
}
35
35
36
36
-
export type RunLoopExit = "rest" | "guard_stop"
36
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
43
-
async function stopLoopForGuard(state: LoopState, hooks: LoopHooks, reason: string): Promise<RunLoopExit> {
43
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
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
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
257
-
return stopLoopForGuard(state, hooks, `loop guard tripped after ${turnCount} turns`)
257
257
+
await applyLoopGuardNudge(state, hooks, `loop guard tripped after ${turnCount} turns`)
258
258
+
turnCount = 0
259
259
+
previousTurnSignature = null
260
260
+
consecutiveIdenticalToolTurns = 0
261
261
+
continue
258
262
}
259
263
260
264
if (consecutiveIdenticalToolTurns >= RUNNER_MAX_IDENTICAL_TOOL_TURNS && previousTurnSignature) {
261
261
-
return stopLoopForGuard(
265
265
+
await applyLoopGuardNudge(
262
266
state,
263
267
hooks,
264
268
`loop guard tripped after ${consecutiveIdenticalToolTurns} identical assistant/tool turns`,
265
269
)
270
270
+
previousTurnSignature = null
271
271
+
consecutiveIdenticalToolTurns = 0
272
272
+
continue
266
273
}
267
274
268
275
await applyLLMCompaction(state, "post-turn")
···
272
279
}
273
280
274
281
export const __loopTest = {
282
282
+
applyLoopGuardNudge,
275
283
applyDiscordSendNudge,
276
284
hasDiscordInputForTurn,
277
285
waitForNextEvent,
···
1
1
import type OpenAI from "openai"
2
2
3
3
-
export type Message = OpenAI.Chat.ChatCompletionMessageParam
3
3
+
export type AssistantMessageWithReasoning = OpenAI.Chat.ChatCompletionAssistantMessageParam & {
4
4
+
reasoning_content?: string
5
5
+
}
6
6
+
7
7
+
export type Message = OpenAI.Chat.ChatCompletionMessageParam | AssistantMessageWithReasoning
4
8
5
9
export type TriggerSource = "discord" | "bsky" | "webhook" | "cron" | "chat"
6
10