tool-runtime.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. import { Effect, Stream } from "effect"
  2. import type { Concurrency } from "effect/Types"
  3. import {
  4. type ContentPart,
  5. type FinishReason,
  6. type LLMError,
  7. type LLMEvent,
  8. LLMRequest,
  9. Message,
  10. type ProviderMetadata,
  11. ToolCallPart,
  12. ToolFailure,
  13. ToolResultPart,
  14. type ToolResultValue,
  15. } from "./schema"
  16. import { type AnyTool, type ExecutableTools, type Tools, toDefinitions } from "./tool"
  17. export interface RuntimeState {
  18. readonly step: number
  19. readonly request: LLMRequest
  20. }
  21. export type StopCondition = (state: RuntimeState) => boolean
  22. export type ToolExecution = "auto" | "none"
  23. interface RunOptionsBase {
  24. readonly request: LLMRequest
  25. readonly concurrency?: Concurrency
  26. readonly stopWhen?: StopCondition
  27. }
  28. export type RunOptions<T extends Tools> = RunOptionsAuto<T & ExecutableTools> | RunOptionsNone<T>
  29. export interface RunOptionsAuto<T extends ExecutableTools> extends RunOptionsBase {
  30. readonly request: LLMRequest
  31. readonly tools: T
  32. readonly toolExecution?: "auto"
  33. }
  34. export interface RunOptionsNone<T extends Tools> extends RunOptionsBase {
  35. readonly request: LLMRequest
  36. readonly tools: T
  37. /** Advertise tool schemas but leave model-emitted tool calls for the caller. */
  38. readonly toolExecution: "none"
  39. }
  40. export type StreamOptions<T extends Tools> = RunOptions<T> & {
  41. readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
  42. }
  43. export const stepCountIs =
  44. (count: number): StopCondition =>
  45. (state) =>
  46. state.step + 1 >= count
  47. /**
  48. * Run a model with typed tools. This helper owns tool orchestration, while the
  49. * caller supplies the actual model stream function. It can advertise schemas
  50. * only (`toolExecution: "none"`), execute one step, or continue model rounds
  51. * when `stopWhen` is provided.
  52. */
  53. export const stream = <T extends Tools>(options: StreamOptions<T>): Stream.Stream<LLMEvent, LLMError> => {
  54. const concurrency = options.concurrency ?? 10
  55. const tools = options.tools as Tools
  56. const runtimeTools = toDefinitions(tools)
  57. const runtimeToolNames = new Set(runtimeTools.map((tool) => tool.name))
  58. const initialRequest =
  59. runtimeTools.length === 0
  60. ? options.request
  61. : LLMRequest.update(options.request, {
  62. tools: [...options.request.tools.filter((tool) => !runtimeToolNames.has(tool.name)), ...runtimeTools],
  63. })
  64. const loop = (request: LLMRequest, step: number): Stream.Stream<LLMEvent, LLMError> =>
  65. Stream.unwrap(
  66. Effect.gen(function* () {
  67. const state: StepState = { assistantContent: [], toolCalls: [], finishReason: undefined }
  68. const modelStream = options
  69. .stream(request)
  70. .pipe(Stream.tap((event) => Effect.sync(() => accumulate(state, event))))
  71. const continuation = Stream.unwrap(
  72. Effect.gen(function* () {
  73. if (state.finishReason !== "tool-calls" || state.toolCalls.length === 0) return Stream.empty
  74. if (options.toolExecution === "none") return Stream.empty
  75. const dispatched = yield* Effect.forEach(
  76. state.toolCalls,
  77. (call) => dispatch(tools, call).pipe(Effect.map((result) => [call, result] as const)),
  78. { concurrency },
  79. )
  80. const resultStream = Stream.fromIterable(dispatched.flatMap(([call, result]) => emitEvents(call, result)))
  81. if (!options.stopWhen) return resultStream
  82. if (options.stopWhen({ step, request })) return resultStream
  83. return resultStream.pipe(Stream.concat(loop(followUpRequest(request, state, dispatched), step + 1)))
  84. }),
  85. )
  86. return modelStream.pipe(Stream.concat(continuation))
  87. }),
  88. )
  89. return loop(initialRequest, 0)
  90. }
  91. interface StepState {
  92. assistantContent: ContentPart[]
  93. toolCalls: ToolCallPart[]
  94. finishReason: FinishReason | undefined
  95. }
  96. const accumulate = (state: StepState, event: LLMEvent) => {
  97. if (event.type === "text-delta") {
  98. appendStreamingText(state, "text", event.text, event.providerMetadata)
  99. return
  100. }
  101. if (event.type === "reasoning-delta") {
  102. appendStreamingText(state, "reasoning", event.text, event.providerMetadata)
  103. return
  104. }
  105. if (event.type === "tool-call") {
  106. const part = ToolCallPart.make({
  107. id: event.id,
  108. name: event.name,
  109. input: event.input,
  110. providerExecuted: event.providerExecuted,
  111. providerMetadata: event.providerMetadata,
  112. })
  113. state.assistantContent.push(part)
  114. if (!event.providerExecuted) state.toolCalls.push(part)
  115. return
  116. }
  117. if (event.type === "tool-result" && event.providerExecuted) {
  118. state.assistantContent.push(
  119. ToolResultPart.make({
  120. id: event.id,
  121. name: event.name,
  122. result: event.result,
  123. providerExecuted: true,
  124. providerMetadata: event.providerMetadata,
  125. }),
  126. )
  127. return
  128. }
  129. if (event.type === "request-finish") {
  130. state.finishReason = event.reason
  131. }
  132. }
  133. const sameProviderMetadata = (left: ProviderMetadata | undefined, right: ProviderMetadata | undefined) =>
  134. left === right || JSON.stringify(left) === JSON.stringify(right)
  135. const mergeProviderMetadata = (left: ProviderMetadata | undefined, right: ProviderMetadata | undefined) => {
  136. if (!left) return right
  137. if (!right) return left
  138. return Object.fromEntries(
  139. Array.from(new Set([...Object.keys(left), ...Object.keys(right)])).map((provider) => [
  140. provider,
  141. { ...left[provider], ...right[provider] },
  142. ]),
  143. )
  144. }
  145. const appendStreamingText = (
  146. state: StepState,
  147. type: "text" | "reasoning",
  148. text: string,
  149. providerMetadata: ProviderMetadata | undefined,
  150. ) => {
  151. const last = state.assistantContent.at(-1)
  152. if (last?.type === type && text.length === 0) {
  153. state.assistantContent[state.assistantContent.length - 1] = {
  154. ...last,
  155. providerMetadata: mergeProviderMetadata(last.providerMetadata, providerMetadata),
  156. }
  157. return
  158. }
  159. if (last?.type === type && sameProviderMetadata(last.providerMetadata, providerMetadata)) {
  160. state.assistantContent[state.assistantContent.length - 1] = { ...last, text: `${last.text}${text}` }
  161. return
  162. }
  163. state.assistantContent.push({ type, text, providerMetadata })
  164. }
  165. const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<ToolResultValue> => {
  166. const tool = tools[call.name]
  167. if (!tool) return Effect.succeed({ type: "error" as const, value: `Unknown tool: ${call.name}` })
  168. if (!tool.execute)
  169. return Effect.succeed({ type: "error" as const, value: `Tool has no execute handler: ${call.name}` })
  170. return decodeAndExecute(tool, call.input).pipe(
  171. Effect.catchTag("LLM.ToolFailure", (failure) =>
  172. Effect.succeed({ type: "error" as const, value: failure.message } satisfies ToolResultValue),
  173. ),
  174. )
  175. }
  176. const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect<ToolResultValue, ToolFailure> =>
  177. tool._decode(input).pipe(
  178. Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
  179. Effect.flatMap((decoded) => tool.execute!(decoded)),
  180. Effect.flatMap((value) =>
  181. tool._encode(value).pipe(
  182. Effect.mapError(
  183. (error) =>
  184. new ToolFailure({
  185. message: `Tool returned an invalid value for its success schema: ${error.message}`,
  186. }),
  187. ),
  188. ),
  189. ),
  190. Effect.map((encoded): ToolResultValue => ({ type: "json", value: encoded })),
  191. )
  192. const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> =>
  193. result.type === "error"
  194. ? [
  195. { type: "tool-error", id: call.id, name: call.name, message: String(result.value) },
  196. { type: "tool-result", id: call.id, name: call.name, result },
  197. ]
  198. : [{ type: "tool-result", id: call.id, name: call.name, result }]
  199. const followUpRequest = (
  200. request: LLMRequest,
  201. state: StepState,
  202. dispatched: ReadonlyArray<readonly [ToolCallPart, ToolResultValue]>,
  203. ) =>
  204. LLMRequest.update(request, {
  205. messages: [
  206. ...request.messages,
  207. Message.assistant(state.assistantContent),
  208. ...dispatched.map(([call, result]) => Message.tool({ id: call.id, name: call.name, result })),
  209. ],
  210. })
  211. export const ToolRuntime = { stream, stepCountIs } as const