11import { Effect } from "effect"
2- import { LLMError , LLMEvent , type ProviderMetadata , type ToolCall } from "../../schema"
2+ import { LLMError , LLMEvent , type ProviderMetadata , type ToolCall , type ToolInputError } from "../../schema"
33import { eventError , parseToolInput , type ToolAccumulator } from "../shared"
44
55type StreamKey = string | number
@@ -53,6 +53,7 @@ const inputStart = (tool: PendingTool) =>
5353 LLMEvent . toolInputStart ( {
5454 id : tool . id ,
5555 name : tool . name ,
56+ providerExecuted : tool . providerExecuted ? true : undefined ,
5657 providerMetadata : tool . providerMetadata ,
5758 } )
5859
@@ -63,19 +64,36 @@ const inputDelta = (tool: PendingTool, text: string) =>
6364 text,
6465 } )
6566
66- const toolCall = ( route : string , tool : PendingTool , inputOverride ?: string ) =>
67- parseToolInput ( route , tool . name , inputOverride ?? tool . input ) . pipe (
68- Effect . map (
69- ( input ) : ToolCall =>
70- LLMEvent . toolCall ( {
71- id : tool . id ,
72- name : tool . name ,
73- input,
74- providerExecuted : tool . providerExecuted ? true : undefined ,
75- providerMetadata : tool . providerMetadata ,
76- } ) ,
67+ const toolCall = ( route : string , tool : PendingTool , inputOverride ?: string ) => {
68+ const raw = inputOverride ?? tool . input
69+ return parseToolInput ( route , tool . name , raw ) . pipe (
70+ Effect . map ( ( input ) : ToolCall | ToolInputError =>
71+ LLMEvent . toolCall ( {
72+ id : tool . id ,
73+ name : tool . name ,
74+ input,
75+ providerExecuted : tool . providerExecuted ? true : undefined ,
76+ providerMetadata : tool . providerMetadata ,
77+ } ) ,
78+ ) ,
79+ Effect . catch ( ( error ) =>
80+ tool . providerExecuted
81+ ? Effect . fail ( error )
82+ : Effect . succeed (
83+ LLMEvent . toolInputError ( {
84+ id : tool . id ,
85+ name : tool . name ,
86+ raw,
87+ } ) ,
88+ ) ,
7789 ) ,
7890 )
91+ }
92+
93+ const finishEvents = ( tool : PendingTool , event : ToolCall | ToolInputError ) : ReadonlyArray < LLMEvent > =>
94+ event . type === "tool-input-error"
95+ ? [ event ]
96+ : [ LLMEvent . toolInputEnd ( { id : tool . id , name : tool . name , providerMetadata : tool . providerMetadata } ) , event ]
7997
8098/** Store the updated tool and produce the optional public delta event. */
8199const appendTool = < K extends StreamKey > (
@@ -158,19 +176,17 @@ export const appendExisting = <K extends StreamKey>(
158176
159177/**
160178 * Finalize one pending tool call: parse the accumulated raw JSON, remove it
161- * from state, and return the optional public `tool-call` event. Missing keys are
162- * a no-op because some providers emit stop events for non-tool content blocks.
179+ * from state, and return either a call or a non-executable local input error.
180+ * Missing keys are a no-op because some providers emit stop events for
181+ * non-tool content blocks.
163182 */
164183export const finish = < K extends StreamKey > ( route : string , tools : State < K > , key : K ) =>
165184 Effect . gen ( function * ( ) {
166185 const tool = tools [ key ]
167186 if ( ! tool ) return { tools }
168187 return {
169188 tools : withoutTool ( tools , key ) ,
170- events : [
171- LLMEvent . toolInputEnd ( { id : tool . id , name : tool . name , providerMetadata : tool . providerMetadata } ) ,
172- yield * toolCall ( route , tool ) ,
173- ] ,
189+ events : finishEvents ( tool , yield * toolCall ( route , tool ) ) ,
174190 }
175191 } )
176192
@@ -185,17 +201,14 @@ export const finishWithInput = <K extends StreamKey>(route: string, tools: State
185201 if ( ! tool ) return { tools }
186202 return {
187203 tools : withoutTool ( tools , key ) ,
188- events : [
189- LLMEvent . toolInputEnd ( { id : tool . id , name : tool . name , providerMetadata : tool . providerMetadata } ) ,
190- yield * toolCall ( route , tool , input ) ,
191- ] ,
204+ events : finishEvents ( tool , yield * toolCall ( route , tool , input ) ) ,
192205 }
193206 } )
194207
195208/**
196209 * Finalize every pending tool call at once. OpenAI Chat has this shape: it does
197- * not emit per-tool stop events, so all accumulated calls finish when the choice
198- * receives a terminal `finish_reason`.
210+ * not emit per-tool stop events, so all accumulated calls finish independently
211+ * when the choice receives a terminal `finish_reason`.
199212 */
200213export const finishAll = < K extends StreamKey > ( route : string , tools : State < K > ) =>
201214 Effect . gen ( function * ( ) {
@@ -205,12 +218,7 @@ export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =
205218 return {
206219 tools : empty < K > ( ) ,
207220 events : yield * Effect . forEach ( pending , ( tool ) =>
208- toolCall ( route , tool ) . pipe (
209- Effect . map ( ( call ) => [
210- LLMEvent . toolInputEnd ( { id : tool . id , name : tool . name , providerMetadata : tool . providerMetadata } ) ,
211- call ,
212- ] ) ,
213- ) ,
221+ toolCall ( route , tool ) . pipe ( Effect . map ( ( event ) => finishEvents ( tool , event ) ) ) ,
214222 ) . pipe ( Effect . map ( ( events ) => events . flat ( ) ) ) ,
215223 }
216224 } )
0 commit comments