my harness for niri
1
fork

Configure Feed

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

at master 809 lines 28 kB view raw
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}