my harness for niri
1import OpenAI from "openai"
2import { runCommand, readFile, editFile, readImageForModel } from "../container/index.js"
3import {
4 listDiscordBackread,
5 listDiscordChannels,
6 listDiscordInbox,
7 markDiscordItem,
8 scanDiscordChannels,
9 setDiscordChannelNote,
10 sendDiscordMessage,
11} from "../discord/state.js"
12import { logMessage } from "../db.js"
13import { emit } from "../stream.js"
14import type { LoopHooks, LoopState } from "./types.js"
15import {
16 API_BASE,
17 FALLBACK_MODEL,
18 FALLBACK_TOOL_CHOICE,
19 FALLBACK_TOKEN_NUDGE_THRESHOLD,
20 MODEL,
21 TOKEN_NUDGE_THRESHOLD,
22 TOOLS,
23 USE_FALLBACK,
24 client,
25 errorSummary,
26 fallbackClient,
27 fallbackContextWindow,
28 maybeCompactConversation,
29 parseImageDetail,
30 parseToolArguments,
31 retryDelayMs,
32 shouldFallback,
33} from "./util.js"
34
35type FunctionToolCall = OpenAI.Chat.ChatCompletionMessageToolCall & { type: "function" }
36type ToolArgs = {
37 command?: string
38 max_lines?: number
39 timeout_ms?: number
40 path?: string
41 start_line?: number
42 end_line?: number
43 old_text?: string
44 new_text?: string
45 note?: string
46 detail?: string
47 limit?: number
48 status?: string
49 item_id?: string
50 action?: string
51 channel_id?: string
52 channel_ids?: string[]
53 before_message_id?: string
54 content?: string
55 source_item_id?: string
56 reference_message_id?: string
57 reply_mode?: string
58 include_unconfigured?: boolean
59 [key: string]: unknown
60}
61type ToolExecutionOutcome = { shouldRest?: boolean; isWait?: boolean }
62enum CycleOutcome {
63 NoTools = "no_tools",
64 ToolsDone = "tools_done",
65 Rest = "rest",
66}
67type ToolArgKey = keyof ToolArgs
68type ArgTuple<K extends readonly ToolArgKey[]> = { [I in keyof K]: ToolArgs[K[I]] }
69
70type ToolExecutionContext = {
71 convId: number
72 state: LoopState
73 hooks: LoopHooks
74 call: FunctionToolCall
75 args: ToolArgs
76}
77
78type ToolHandler = (ctx: ToolExecutionContext) => Promise<ToolExecutionOutcome>
79
80function latestAssistantContent(state: LoopState): string {
81 for (let i = state.conversation.length - 1; i >= 0; i--) {
82 const message = state.conversation[i]
83 if (!message || message.role !== "assistant") continue
84 if (typeof message.content === "string") return message.content.trim()
85 return ""
86 }
87 return ""
88}
89
90/**
91 * Type guard that narrows a tool call to function-call shape.
92 *
93 * @param call - Raw tool call from the assistant response.
94 * @returns `true` when the call is a function tool call.
95 */
96function isFunctionToolCall(call: OpenAI.Chat.ChatCompletionMessageToolCall): call is FunctionToolCall {
97 return call.type === "function"
98}
99
100/**
101 * Sleeps for a fixed duration.
102 *
103 * @param ms - Delay in milliseconds.
104 * @returns A promise that resolves after the timeout.
105 */
106function sleep(ms: number): Promise<void> {
107 return new Promise((resolve) => setTimeout(resolve, ms))
108}
109
110function formatRetryAt(retryAfterMs: number): string {
111 const retryAt = new Date(Date.now() + retryAfterMs)
112 const local = retryAt.toLocaleString(undefined, {
113 hour12: false,
114 timeZoneName: "short",
115 })
116 return `${local} (${retryAt.toISOString()})`
117}
118
119function shouldRetryFallbackWithAutoToolChoice(err: unknown): boolean {
120 if (!(err instanceof OpenAI.APIError)) return false
121 return /no endpoints found that support the provided 'tool_choice' value/i.test(err.message)
122}
123
124async function createFallbackCompletion(state: LoopState): Promise<OpenAI.Chat.ChatCompletion> {
125 const request = {
126 model: FALLBACK_MODEL,
127 messages: state.conversation,
128 tools: TOOLS,
129 tool_choice: FALLBACK_TOOL_CHOICE,
130 } as const
131
132 try {
133 return await fallbackClient.chat.completions.create(request)
134 } catch (err) {
135 if (request.tool_choice !== "auto" && shouldRetryFallbackWithAutoToolChoice(err)) {
136 console.warn(
137 `[fallback] provider rejected tool_choice=${request.tool_choice}; retrying with tool_choice=auto`,
138 )
139 return fallbackClient.chat.completions.create({
140 ...request,
141 tool_choice: "auto",
142 })
143 }
144 throw err
145 }
146}
147
148/**
149 * Appends an assistant message to state and persists it to conversation logs.
150 *
151 * @param convId - Active conversation id.
152 * @param state - Mutable loop state.
153 * @param msg - Assistant message to append.
154 */
155function addAssistantMessage(convId: number, state: LoopState, msg: OpenAI.Chat.ChatCompletionMessage): void {
156 state.conversation.push(msg)
157 logMessage(convId, msg.role, msg.content ?? "", msg.tool_calls ?? undefined)
158}
159
160/**
161 * Applies token usage from a completion response to loop state counters.
162 *
163 * @param state - Mutable loop state.
164 * @param usage - Completion usage payload (if provided by the API).
165 */
166function applyUsage(state: LoopState, usage: OpenAI.Completions.CompletionUsage | undefined): void {
167 if (!usage) return
168 state.tokenCount += usage.total_tokens
169 if (usage.prompt_tokens) state.contextSize = usage.prompt_tokens
170 console.log(`[tokens] +${usage.total_tokens} total=${state.tokenCount}`)
171}
172
173/**
174 * Emits model reasoning text when exposed by the provider.
175 *
176 * Supports both `reasoning_content` and `<think>...</think>` wrappers.
177 *
178 * @param msg - Assistant message to inspect for reasoning traces.
179 */
180function emitThinking(msg: OpenAI.Chat.ChatCompletionMessage): void {
181 // Emit thinking / reasoning trace if the model exposes it.
182 // Most OpenAI-compatible APIs (ZhiPu, DeepSeek, QWen...) put it in
183 // reasoning_content; some models wrap it in <think>...</think> tags.
184 const rawMsg = msg as unknown as Record<string, unknown>
185 let thinkingText: string | null = null
186
187 if (typeof rawMsg.reasoning_content === "string" && rawMsg.reasoning_content.trim()) {
188 thinkingText = rawMsg.reasoning_content.trim()
189 } else if (typeof msg.content === "string") {
190 const match = msg.content.match(/^<think>([\s\S]*?)<\/think>\s*/i)
191 if (match) {
192 thinkingText = match[1]!.trim()
193 // Strip the <think> block from the visible content before emitting text.
194 ;(msg as unknown as Record<string, unknown>).content = msg.content.slice(match[0].length)
195 }
196 }
197
198 if (thinkingText) emit({ type: "thinking", text: thinkingText })
199}
200
201/**
202 * Appends a tool-role message and logs it for persistence.
203 *
204 * @param convId - Active conversation id.
205 * @param state - Mutable loop state.
206 * @param call - Tool call being satisfied.
207 * @param content - Tool result text to persist.
208 * @returns The persisted tool content string.
209 */
210function pushToolMessage(convId: number, state: LoopState, call: FunctionToolCall, content: string): string {
211 const toolMsg = {
212 role: "tool" as const,
213 tool_call_id: call.id,
214 content,
215 }
216 state.conversation.push(toolMsg)
217 logMessage(convId, "tool", toolMsg.content, undefined, call.id)
218 return toolMsg.content
219}
220
221/**
222 * Records a tool result message and emits it to stream subscribers.
223 *
224 * @param convId - Active conversation id.
225 * @param state - Mutable loop state.
226 * @param call - Tool call being satisfied.
227 * @param name - Tool name to emit.
228 * @param args - Tool args payload to emit.
229 * @param content - Tool result content to persist.
230 * @returns The persisted tool result content.
231 */
232function recordToolResult(
233 convId: number,
234 state: LoopState,
235 call: FunctionToolCall,
236 name: string,
237 args: Record<string, unknown>,
238 content: string,
239): string {
240 const result = pushToolMessage(convId, state, call, content)
241 emit({ type: "tool", name, args, result })
242 return result
243}
244
245/**
246 * Normalizes thrown values into tool-friendly error text.
247 *
248 * @param err - Unknown thrown value.
249 * @returns Error string prefixed with `error:`.
250 */
251function toolError(err: unknown): string {
252 return `error: ${err instanceof Error ? err.message : String(err)}`
253}
254
255type StandardToolSpec<
256 RunKeys extends readonly ToolArgKey[],
257 LogKeys extends readonly ToolArgKey[] = RunKeys,
258 EmitKeys extends readonly ToolArgKey[] | undefined = undefined,
259> = {
260 name: string
261 logArgKeys: LogKeys
262 runArgKeys: RunKeys
263 run: (...values: ArgTuple<RunKeys>) => Promise<string>
264 emptyFallback?: string
265 emitArgKeys?: EmitKeys
266 previewChars?: number
267}
268
269/**
270 * Resolves argument values by key order from a tool argument object.
271 *
272 * @param args - Parsed tool arguments.
273 * @param keys - Keys to project in order.
274 * @returns Ordered list of argument values.
275 */
276function argsByKeys<K extends readonly ToolArgKey[]>(args: ToolArgs, keys: K): ArgTuple<K> {
277 return keys.map((key) => args[key]) as ArgTuple<K>
278}
279
280/**
281 * Picks a subset of argument keys into a new object.
282 *
283 * @param args - Parsed tool arguments.
284 * @param keys - Keys to include.
285 * @returns Object containing only requested keys.
286 */
287function pickArgsByKeys<K extends readonly ToolArgKey[]>(args: ToolArgs, keys: K): Record<K[number], ToolArgs[K[number]]> {
288 return Object.fromEntries(keys.map((key) => [key, args[key]])) as Record<K[number], ToolArgs[K[number]]>
289}
290
291/**
292 * Executes a standard "run command then record result" tool pattern.
293 *
294 * @param ctx - Tool execution context.
295 * @param spec - Declarative tool behavior and argument mapping.
296 * @returns Empty outcome object for continued loop flow.
297 */
298async function runStandardTool<
299 RunKeys extends readonly ToolArgKey[],
300 LogKeys extends readonly ToolArgKey[] = RunKeys,
301 EmitKeys extends readonly ToolArgKey[] | undefined = undefined,
302>(
303 ctx: ToolExecutionContext,
304 spec: StandardToolSpec<RunKeys, LogKeys, EmitKeys>,
305): Promise<ToolExecutionOutcome> {
306 console.log(`[${spec.name}]`, ...argsByKeys(ctx.args, spec.logArgKeys))
307 const raw = await spec.run(...argsByKeys(ctx.args, spec.runArgKeys)).catch(toolError)
308 const previewChars = spec.previewChars ?? 200
309 const preview = previewChars === 0 ? raw : raw.slice(0, previewChars)
310 console.log(`[${spec.name} result]`, preview)
311 const content = raw || spec.emptyFallback || raw
312 const emitArgs = spec.emitArgKeys ? pickArgsByKeys(ctx.args, spec.emitArgKeys) : ctx.args
313 recordToolResult(ctx.convId, ctx.state, ctx.call, spec.name, emitArgs, content)
314 return {}
315}
316
317/**
318 * Fetches the next assistant completion, including fallback/backoff behavior.
319 *
320 * @param state - Mutable loop state containing current conversation/context.
321 * @returns The next chat completion response.
322 * @throws If the primary request fails with a non-fallback error condition.
323 */
324async function fetchCompletion(state: LoopState): Promise<OpenAI.Chat.ChatCompletion> {
325 while (true) {
326 if (USE_FALLBACK) {
327 const fallbackWindow = fallbackContextWindow(state.conversation)
328 if (fallbackWindow.nearLimit) {
329 console.warn(
330 `[fallback] prompt estimate ${fallbackWindow.estimate} nearing fallback limit ${fallbackWindow.softLimit} (${FALLBACK_MODEL})`,
331 )
332 }
333
334 return createFallbackCompletion(state)
335 }
336
337 try {
338 return await client!.chat.completions.create({
339 model: MODEL,
340 messages: state.conversation,
341 tools: TOOLS,
342 tool_choice: "required",
343 })
344 } catch (primaryErr) {
345 if (!shouldFallback(primaryErr)) {
346 if (primaryErr instanceof OpenAI.APIError) {
347 console.error(`[api] ${primaryErr.status} ${primaryErr.message} - model=${MODEL} api=${API_BASE}`)
348 }
349 throw primaryErr
350 }
351
352 const fallbackWindow = fallbackContextWindow(state.conversation)
353 if (fallbackWindow.skip) {
354 console.warn(
355 `[api] primary down (${errorSummary(primaryErr)}) and fallback context estimate ${fallbackWindow.estimate} exceeds hard limit ${fallbackWindow.hardLimit}; retrying primary after backoff`,
356 )
357 const retryAfter = retryDelayMs(primaryErr)
358 console.log(
359 `[runner] backing off ${Math.ceil(retryAfter / 1000)}s (until ${formatRetryAt(retryAfter)}) before retrying primary...`,
360 )
361 await sleep(retryAfter)
362 continue
363 }
364
365 if (fallbackWindow.nearLimit) {
366 console.warn(
367 `[fallback] prompt estimate ${fallbackWindow.estimate} nearing fallback limit ${fallbackWindow.softLimit} (${FALLBACK_MODEL})`,
368 )
369 }
370
371 console.warn(`[api] primary down (${errorSummary(primaryErr)}) - switching to fallback`)
372 try {
373 return await createFallbackCompletion(state)
374 } catch (fallbackErr) {
375 console.warn(
376 `[api] fallback failed (${errorSummary(fallbackErr)}) after primary failure (${errorSummary(primaryErr)}); retrying primary after backoff`,
377 )
378 const retryAfter = retryDelayMs(primaryErr)
379 console.log(
380 `[runner] backing off ${Math.ceil(retryAfter / 1000)}s (until ${formatRetryAt(retryAfter)}) before retrying primary...`,
381 )
382 await sleep(retryAfter)
383 }
384 }
385 }
386}
387
388/**
389 * Marks remaining tool calls as skipped after an interrupting incoming event.
390 *
391 * @param convId - Active conversation id.
392 * @param state - Mutable loop state.
393 * @param calls - Ordered function tool calls from the current assistant turn.
394 * @param startIndex - First unexecuted tool call index.
395 * @param source - Event source that caused interruption.
396 */
397function skipRemainingToolCalls(
398 convId: number,
399 state: LoopState,
400 calls: readonly FunctionToolCall[],
401 startIndex: number,
402 source: string,
403): void {
404 calls.slice(startIndex).forEach((pendingCall) => {
405 const skippedContent = `skipped: interrupted by incoming ${source} event.`
406 const result = pushToolMessage(convId, state, pendingCall, skippedContent)
407 emit({ type: "tool", name: pendingCall.function.name, args: { _skipped: true }, result })
408 })
409}
410
411/**
412 * Injects a pending event immediately after a tool result when available.
413 *
414 * @param convId - Active conversation id.
415 * @param state - Mutable loop state.
416 * @param hooks - Loop hooks for event injection.
417 * @param calls - Ordered function tool calls for this assistant turn.
418 * @param nextIndex - Index of the next tool call that would have run.
419 * @returns `true` when interruption occurred and remaining calls were skipped.
420 */
421function maybeInterruptAfterTool(
422 convId: number,
423 state: LoopState,
424 hooks: LoopHooks,
425 calls: readonly FunctionToolCall[],
426 nextIndex: number,
427): boolean {
428 if (state.pendingInputs.length === 0) return false
429
430 const incoming = state.pendingInputs.shift()!
431 hooks.injectIncomingEvent(convId, incoming)
432 skipRemainingToolCalls(convId, state, calls, nextIndex, incoming.source)
433 return true
434}
435
436/**
437 * Builds the function-tool handler table for this loop.
438 *
439 * @returns Tool-name keyed async handler map.
440 */
441function buildToolHandlers(): Record<string, ToolHandler> {
442 return {
443 wait: async ({ convId, state, hooks, call, args }) => {
444 recordToolResult(convId, state, call, "wait", args, "Waiting for next event.")
445 console.log("[runner] niri is waiting for next event...")
446 const incoming = await hooks.waitForEvent()
447 hooks.injectIncomingEvent(convId, incoming)
448 return { isWait: true }
449 },
450
451 rest: async ({ convId, state, hooks, call, args }) => {
452 if (args.note) console.log("[runner] rest note:", args.note)
453 recordToolResult(convId, state, call, "rest", args, "Goodnight.")
454 await hooks.clearSession()
455 return { shouldRest: true }
456 },
457
458 shell: (ctx) =>
459 runStandardTool(ctx, {
460 name: "shell",
461 logArgKeys: ["command", "timeout_ms"] as const,
462 runArgKeys: ["command", "max_lines", "timeout_ms"] as const,
463 run: (command, max_lines, timeout_ms) =>
464 runCommand(command as string, max_lines as number | undefined, timeout_ms as number | undefined),
465 emptyFallback: "(no output)",
466 }),
467
468 read_file: (ctx) =>
469 runStandardTool(ctx, {
470 name: "read_file",
471 logArgKeys: ["path", "start_line", "end_line", "timeout_ms"] as const,
472 runArgKeys: ["path", "start_line", "end_line", "timeout_ms"] as const,
473 run: (path, start_line, end_line, timeout_ms) =>
474 readFile(path as string, start_line as number | undefined, end_line as number | undefined, timeout_ms as number | undefined),
475 emptyFallback: "(empty file)",
476 }),
477
478 edit_file: (ctx) =>
479 runStandardTool(ctx, {
480 name: "edit_file",
481 logArgKeys: ["path", "timeout_ms"] as const,
482 runArgKeys: ["path", "old_text", "new_text", "timeout_ms"] as const,
483 run: async (path, old_text, new_text, timeout_ms) => {
484 const editResult = await editFile(
485 path as string,
486 old_text as string,
487 new_text as string,
488 timeout_ms as number | undefined,
489 )
490 return editResult.ok ? editResult.message : `error: ${editResult.message}`
491 },
492 emitArgKeys: ["path"] as const,
493 previewChars: 0,
494 }),
495
496 image_tool: async ({ convId, state, call, args }) => {
497 console.log("[image_tool]", args.path, args.timeout_ms)
498 let result: string
499
500 try {
501 const detail = parseImageDetail(args.detail)
502 const image = await readImageForModel(args.path as string, args.timeout_ms as number | undefined)
503 const note =
504 typeof args.note === "string" && args.note.trim()
505 ? args.note.trim()
506 : `Please inspect this image: ${image.path}`
507
508 result = pushToolMessage(convId, state, call, `attached ${image.path} (${image.mime}, ${image.bytes} bytes)`)
509
510 const imageMessage: OpenAI.Chat.ChatCompletionUserMessageParam = {
511 role: "user",
512 content: [
513 { type: "text", text: note },
514 { type: "image_url", image_url: { url: image.dataUrl, detail } },
515 ],
516 }
517 state.conversation.push(imageMessage)
518 logMessage(convId, "user", `[image_tool] ${note}\npath=${image.path}\ndetail=${detail}`)
519 } catch (err) {
520 result = recordToolResult(convId, state, call, "image_tool", { path: args.path, detail: args.detail }, toolError(err))
521 return {}
522 }
523
524 emit({ type: "tool", name: "image_tool", args: { path: args.path, detail: args.detail }, result })
525 return {}
526 },
527
528 discord_scan: (ctx) =>
529 runStandardTool(ctx, {
530 name: "discord_scan",
531 logArgKeys: ["limit", "before_message_id"] as const,
532 runArgKeys: ["limit", "channel_ids", "before_message_id"] as const,
533 run: async (limit, channel_ids, before_message_id) =>
534 JSON.stringify(
535 await scanDiscordChannels({
536 limit: limit as number | undefined,
537 channelIds: channel_ids as string[] | string | undefined,
538 beforeMessageId: before_message_id as string | undefined,
539 }),
540 null,
541 2,
542 ),
543 }),
544
545 discord_inbox: (ctx) =>
546 runStandardTool(ctx, {
547 name: "discord_inbox",
548 logArgKeys: ["limit", "status"] as const,
549 runArgKeys: ["limit", "status"] as const,
550 run: async (limit, status) =>
551 JSON.stringify(
552 listDiscordInbox(limit as number | undefined, status as string | string[] | undefined),
553 null,
554 2,
555 ),
556 }),
557
558 discord_backread: (ctx) =>
559 runStandardTool(ctx, {
560 name: "discord_backread",
561 logArgKeys: ["channel_id", "limit", "before_message_id"] as const,
562 runArgKeys: ["channel_id", "limit", "before_message_id"] as const,
563 run: async (channel_id, limit, before_message_id) =>
564 JSON.stringify(
565 listDiscordBackread(
566 channel_id as string,
567 limit as number | undefined,
568 before_message_id as string | undefined,
569 ),
570 null,
571 2,
572 ),
573 }),
574
575 discord_mark: (ctx) =>
576 runStandardTool(ctx, {
577 name: "discord_mark",
578 logArgKeys: ["item_id", "status", "action"] as const,
579 runArgKeys: ["item_id", "status", "note", "action"] as const,
580 run: async (item_id, status, note, action) => {
581 markDiscordItem(
582 item_id as string,
583 status as "pending" | "seen" | "acted" | "ignored",
584 (note as string | undefined) ?? "",
585 (action as "none" | "replied" | "messaged" | "dismissed" | "noted" | undefined) ?? "none",
586 )
587 return JSON.stringify(
588 {
589 ok: true,
590 item_id,
591 status,
592 action: action ?? "none",
593 note: note ?? "",
594 },
595 null,
596 2,
597 )
598 },
599 }),
600
601 discord_send: (ctx) =>
602 runStandardTool(ctx, {
603 name: "discord_send",
604 logArgKeys: ["channel_id", "source_item_id", "reply_mode"] as const,
605 runArgKeys: ["channel_id", "content", "source_item_id", "reply_mode", "reference_message_id"] as const,
606 run: async (channel_id, content, source_item_id, reply_mode, reference_message_id) =>
607 JSON.stringify(
608 await sendDiscordMessage({
609 channelId: channel_id as string,
610 content: content as string,
611 sourceItemId: source_item_id as string | undefined,
612 replyMode: reply_mode as string | undefined,
613 referenceMessageId: reference_message_id as string | undefined,
614 }),
615 null,
616 2,
617 ),
618 }),
619
620 discord_channels: (ctx) =>
621 runStandardTool(ctx, {
622 name: "discord_channels",
623 logArgKeys: ["include_unconfigured"] as const,
624 runArgKeys: ["include_unconfigured"] as const,
625 run: async (include_unconfigured) =>
626 JSON.stringify(listDiscordChannels(include_unconfigured !== false), null, 2),
627 }),
628
629 discord_channel_note: (ctx) =>
630 runStandardTool(ctx, {
631 name: "discord_channel_note",
632 logArgKeys: ["channel_id"] as const,
633 runArgKeys: ["channel_id", "note"] as const,
634 run: async (channel_id, note) =>
635 JSON.stringify(setDiscordChannelNote(channel_id as string, (note as string | undefined) ?? ""), null, 2),
636 }),
637 }
638}
639
640/**
641 * Executes a single function tool call end-to-end.
642 *
643 * Parses arguments, dispatches to the tool handler, and manages in-flight state.
644 *
645 * @param convId - Active conversation id.
646 * @param state - Mutable loop state.
647 * @param hooks - Loop hooks for side effects and event flow.
648 * @param handlers - Tool handler map.
649 * @param call - Function tool call to execute.
650 * @returns Tool execution outcome for loop control flow.
651 */
652async function executeToolCall(
653 convId: number,
654 state: LoopState,
655 hooks: LoopHooks,
656 handlers: Record<string, ToolHandler>,
657 call: FunctionToolCall,
658): Promise<ToolExecutionOutcome> {
659 const parsed = parseToolArguments(call.function.arguments)
660 if (!parsed.ok) {
661 const errorText = `error: invalid arguments for ${call.function.name}: ${parsed.error}`
662 recordToolResult(convId, state, call, call.function.name, { _parse_error: parsed.error }, errorText)
663 return {}
664 }
665
666 if ((call.function.name === "wait" || call.function.name === "rest") && latestAssistantContent(state).length === 0) {
667 const errorText = `error: ${call.function.name} requires non-empty assistant content. say something before calling ${call.function.name}.`
668 recordToolResult(convId, state, call, call.function.name, { _content_required: true }, errorText)
669 return {}
670 }
671
672 const isWaitTool = call.function.name === "wait"
673 if (!isWaitTool) state.toolInFlight = true
674
675 try {
676 const handler = handlers[call.function.name]
677 if (!handler) return {}
678 return await handler({ convId, state, hooks, call, args: parsed.args })
679 } finally {
680 if (!isWaitTool) {
681 state.toolInFlight = false
682 hooks.flushDeferredEvents()
683 }
684 }
685}
686
687/**
688 * Processes all function tool calls requested in one assistant turn.
689 *
690 * @param convId - Active conversation id.
691 * @param state - Mutable loop state.
692 * @param hooks - Loop hooks for side effects and event flow.
693 * @param calls - Function tool calls to execute in order.
694 * @returns Cycle outcome indicating whether to continue or rest.
695 */
696async function processToolCalls(
697 convId: number,
698 state: LoopState,
699 hooks: LoopHooks,
700 calls: readonly FunctionToolCall[],
701): Promise<CycleOutcome> {
702 const handlers = buildToolHandlers()
703
704 for (let i = 0; i < calls.length; i++) {
705 const call = calls[i]!
706 const outcome = await executeToolCall(convId, state, hooks, handlers, call)
707
708 if (outcome.shouldRest) return CycleOutcome.Rest
709 if (outcome.isWait) continue
710
711 const interrupted = maybeInterruptAfterTool(convId, state, hooks, calls, i + 1)
712 if (interrupted) return CycleOutcome.ToolsDone
713 }
714
715 return CycleOutcome.ToolsDone
716}
717
718/**
719 * Processes a single assistant turn: completion, reasoning/text emission, tools.
720 *
721 * @param convId - Active conversation id.
722 * @param state - Mutable loop state.
723 * @param hooks - Loop hooks for side effects and event flow.
724 * @returns Cycle outcome indicating whether tools ran, none ran, or rest occurred.
725 * @throws Propagates non-fallback completion errors from `fetchCompletion`.
726 */
727async function processAssistantTurn(convId: number, state: LoopState, hooks: LoopHooks): Promise<CycleOutcome> {
728 const response = await fetchCompletion(state)
729 applyUsage(state, response.usage)
730
731 const msg = response.choices[0].message
732 addAssistantMessage(convId, state, msg)
733 emitThinking(msg)
734
735 // With tool_choice: "required" this shouldn't happen but handle gracefully anyway.
736 const functionCalls = (msg.tool_calls ?? []).filter(isFunctionToolCall)
737 if (msg.content) emit({ type: "text", text: msg.content })
738 if (functionCalls.length === 0) return CycleOutcome.NoTools
739
740 return processToolCalls(convId, state, hooks, functionCalls)
741}
742
743/**
744 * Appends a system nudge when context token usage passes threshold.
745 *
746 * @param state - Mutable loop state.
747 */
748function applyContextNudge(state: LoopState): void {
749 const tokenNudgeThreshold = USE_FALLBACK ? FALLBACK_TOKEN_NUDGE_THRESHOLD : TOKEN_NUDGE_THRESHOLD
750 const contextProvider = USE_FALLBACK ? "fallback" : "primary"
751
752 if (state.contextSize >= tokenNudgeThreshold) {
753 state.conversation.push({
754 role: "user",
755 content: `[system] context at ~${Math.round(state.contextSize / 1000)}k tokens (${contextProvider}). Consider wrapping up soon to stay within the context limit.`,
756 })
757 }
758}
759
760/**
761 * Compacts older context into a rolling summary when prompt size is too large.
762 *
763 * @param state - Mutable loop state.
764 * @param phase - Log label for pre/post-turn compaction runs.
765 * @returns `true` when conversation messages were compacted.
766 */
767function applyRollingCompaction(state: LoopState, phase: "pre-turn" | "post-turn"): boolean {
768 const result = maybeCompactConversation(state.conversation, state.contextSize)
769 if (!result.compacted) return false
770
771 state.conversation = result.messages
772 state.contextSize = result.estimateAfter
773
774 console.log(
775 `[context] ${phase} compacted ${result.messagesRemoved} messages across ${result.chunks} chunks (${result.estimateBefore} -> ${result.estimateAfter})`,
776 )
777 return true
778}
779
780/**
781 * Executes the main assistant/tool loop for an active conversation.
782 *
783 * The loop repeatedly:
784 * 1) fetches a model turn,
785 * 2) executes requested tools,
786 * 3) persists session state,
787 * until a rest action terminates the cycle.
788 *
789 * @param convId - Active conversation id used for persistence/logging.
790 * @param state - Mutable loop state for conversation, tokens, and pending events.
791 * @param hooks - Host-provided lifecycle and event hooks.
792 * @returns A promise that resolves when the loop exits.
793 */
794export async function runLoop(convId: number, state: LoopState, hooks: LoopHooks): Promise<void> {
795 while (true) {
796 const preCompacted = applyRollingCompaction(state, "pre-turn")
797 if (preCompacted) await hooks.saveSession()
798
799 const outcome = await processAssistantTurn(convId, state, hooks)
800 if (outcome === CycleOutcome.Rest) break
801
802 applyRollingCompaction(state, "post-turn")
803 if (outcome !== CycleOutcome.NoTools) {
804 applyContextNudge(state)
805 }
806
807 await hooks.saveSession()
808 }
809}