my harness for niri
1
fork

Configure Feed

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

at main 182 lines 6.2 kB view raw
1import OpenAI from "openai" 2import { logMessage } from "../db.js" 3import { emit } from "../stream.js" 4import { latestAssistantContent } from "./loop-content.js" 5import type { 6 ArgTuple, 7 FunctionToolCall, 8 ToolArgKey, 9 ToolArgs, 10 ToolExecutionContext, 11 ToolExecutionOutcome, 12 ToolHandler, 13} from "./loop-shared.js" 14import type { LoopHooks, LoopState } from "./types.js" 15import { parseToolArguments } from "./util.js" 16 17type StandardToolSpec< 18 RunKeys extends readonly ToolArgKey[], 19 LogKeys extends readonly ToolArgKey[] = RunKeys, 20 EmitKeys extends readonly ToolArgKey[] | undefined = undefined, 21> = { 22 name: string 23 logArgKeys: LogKeys 24 runArgKeys: RunKeys 25 run: (...values: ArgTuple<RunKeys>) => Promise<string> 26 emptyFallback?: string 27 emitArgKeys?: EmitKeys 28 previewChars?: number 29} 30 31/** 32 * Appends a tool-role message to conversation state and persistence logs. 33 * 34 * @param convId - Active conversation id. 35 * @param state - Mutable loop state. 36 * @param call - Tool call being satisfied. 37 * @param content - Tool result content. 38 * @returns Persisted tool content. 39 */ 40export function pushToolMessage(convId: number, state: LoopState, call: FunctionToolCall, content: string): string { 41 const toolMsg = { 42 role: "tool" as const, 43 tool_call_id: call.id, 44 content, 45 } 46 state.conversation.push(toolMsg) 47 logMessage(convId, "tool", toolMsg.content, undefined, call.id) 48 return toolMsg.content 49} 50 51function hasToolResponse(state: LoopState, call: FunctionToolCall): boolean { 52 return state.conversation.some( 53 (message) => 54 message.role === "tool" && 55 (message as OpenAI.Chat.ChatCompletionToolMessageParam).tool_call_id === call.id, 56 ) 57} 58 59/** 60 * Records a tool result and emits it to stream subscribers. 61 * 62 * @param convId - Active conversation id. 63 * @param state - Mutable loop state. 64 * @param call - Tool call being satisfied. 65 * @param name - Tool name. 66 * @param args - Tool args payload. 67 * @param content - Tool result content. 68 * @returns Persisted tool content. 69 */ 70export function recordToolResult( 71 convId: number, 72 state: LoopState, 73 call: FunctionToolCall, 74 name: string, 75 args: Record<string, unknown>, 76 content: string, 77): string { 78 const result = pushToolMessage(convId, state, call, content) 79 emit({ type: "tool", name, args, result }) 80 return result 81} 82 83/** 84 * Normalizes thrown values into tool-friendly error text. 85 * 86 * @param err - Unknown thrown value. 87 * @returns Error string prefixed with `error:`. 88 */ 89export function toolError(err: unknown): string { 90 return `error: ${err instanceof Error ? err.message : String(err)}` 91} 92 93function argsByKeys<K extends readonly ToolArgKey[]>(args: ToolArgs, keys: K): ArgTuple<K> { 94 return keys.map((key) => args[key]) as ArgTuple<K> 95} 96 97function pickArgsByKeys<K extends readonly ToolArgKey[]>(args: ToolArgs, keys: K): Record<K[number], ToolArgs[K[number]]> { 98 return Object.fromEntries(keys.map((key) => [key, args[key]])) as Record<K[number], ToolArgs[K[number]]> 99} 100 101/** 102 * Executes a standard "run command then record result" tool pattern. 103 * 104 * @param ctx - Tool execution context. 105 * @param spec - Declarative tool behavior and argument mapping. 106 * @returns Empty outcome object for continued loop flow. 107 */ 108export async function runStandardTool< 109 RunKeys extends readonly ToolArgKey[], 110 LogKeys extends readonly ToolArgKey[] = RunKeys, 111 EmitKeys extends readonly ToolArgKey[] | undefined = undefined, 112>( 113 ctx: ToolExecutionContext, 114 spec: StandardToolSpec<RunKeys, LogKeys, EmitKeys>, 115): Promise<ToolExecutionOutcome> { 116 console.log(`[${spec.name}]`, ...argsByKeys(ctx.args, spec.logArgKeys)) 117 const raw = await spec.run(...argsByKeys(ctx.args, spec.runArgKeys)).catch(toolError) 118 const previewChars = spec.previewChars ?? 200 119 const preview = previewChars === 0 ? raw : raw.slice(0, previewChars) 120 console.log(`[${spec.name} result]`, preview) 121 const content = raw || spec.emptyFallback || raw 122 const emitArgs = spec.emitArgKeys ? pickArgsByKeys(ctx.args, spec.emitArgKeys) : ctx.args 123 recordToolResult(ctx.convId, ctx.state, ctx.call, spec.name, emitArgs, content) 124 return {} 125} 126 127/** 128 * Executes one function tool call end-to-end. 129 * 130 * @param convId - Active conversation id. 131 * @param state - Mutable loop state. 132 * @param hooks - Loop hooks for side effects and event flow. 133 * @param handlers - Tool handler map. 134 * @param call - Function tool call to execute. 135 * @returns Tool execution outcome for loop control flow. 136 */ 137export async function executeToolCall( 138 convId: number, 139 state: LoopState, 140 hooks: LoopHooks, 141 handlers: Record<string, ToolHandler>, 142 call: FunctionToolCall, 143): Promise<ToolExecutionOutcome> { 144 const parsed = parseToolArguments(call.function.arguments) 145 if (!parsed.ok) { 146 const errorText = `error: invalid arguments for ${call.function.name}: ${parsed.error}` 147 recordToolResult(convId, state, call, call.function.name, { _parse_error: parsed.error }, errorText) 148 return {} 149 } 150 151 if ((call.function.name === "wait" || call.function.name === "rest") && latestAssistantContent(state).length === 0) { 152 console.warn( 153 `[runner] ${call.function.name} called with empty assistant content; allowing tool-only turn (provider emitted no text).`, 154 ) 155 } 156 157 const isWaitTool = call.function.name === "wait" || call.function.name === "wait_then_continue" 158 if (!isWaitTool) state.toolInFlight = true 159 160 try { 161 const handler = handlers[call.function.name] 162 if (!handler) { 163 const errorText = `error: unknown tool ${call.function.name}` 164 recordToolResult(convId, state, call, call.function.name, { _unknown_tool: call.function.name }, errorText) 165 return {} 166 } 167 return await handler({ convId, state, hooks, call, args: parsed.args }) 168 } catch (err) { 169 const errorText = toolError(err) 170 if (!hasToolResponse(state, call)) { 171 recordToolResult(convId, state, call, call.function.name, { _handler_error: true }, errorText) 172 } else { 173 console.warn(`[runner] ${call.function.name} failed after recording tool response: ${errorText}`) 174 } 175 return {} 176 } finally { 177 if (!isWaitTool) { 178 state.toolInFlight = false 179 hooks.flushDeferredEvents() 180 } 181 } 182}