my harness for niri
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}