tool-runtime.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  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. 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, undefined)
  99. return
  100. }
  101. if (event.type === "reasoning-delta") {
  102. appendStreamingText(state, "reasoning", event.text, undefined)
  103. return
  104. }
  105. if (event.type === "reasoning-end") {
  106. appendStreamingText(state, "reasoning", "", event.providerMetadata)
  107. return
  108. }
  109. if (event.type === "text-end") {
  110. appendStreamingText(state, "text", "", event.providerMetadata)
  111. return
  112. }
  113. if (event.type === "tool-call") {
  114. const part = ToolCallPart.make({
  115. id: event.id,
  116. name: event.name,
  117. input: event.input,
  118. providerExecuted: event.providerExecuted,
  119. providerMetadata: event.providerMetadata,
  120. })
  121. state.assistantContent.push(part)
  122. if (!event.providerExecuted) state.toolCalls.push(part)
  123. return
  124. }
  125. if (event.type === "tool-result" && event.providerExecuted) {
  126. state.assistantContent.push(
  127. ToolResultPart.make({
  128. id: event.id,
  129. name: event.name,
  130. result: event.result,
  131. providerExecuted: true,
  132. providerMetadata: event.providerMetadata,
  133. }),
  134. )
  135. return
  136. }
  137. if (event.type === "step-finish" || event.type === "request-finish") {
  138. state.finishReason = event.reason === "stop" && state.toolCalls.length > 0 ? "tool-calls" : event.reason
  139. }
  140. }
  141. const sameProviderMetadata = (left: ProviderMetadata | undefined, right: ProviderMetadata | undefined) =>
  142. left === right || JSON.stringify(left) === JSON.stringify(right)
  143. const mergeProviderMetadata = (left: ProviderMetadata | undefined, right: ProviderMetadata | undefined) => {
  144. if (!left) return right
  145. if (!right) return left
  146. return Object.fromEntries(
  147. Array.from(new Set([...Object.keys(left), ...Object.keys(right)])).map((provider) => [
  148. provider,
  149. { ...left[provider], ...right[provider] },
  150. ]),
  151. )
  152. }
  153. const appendStreamingText = (
  154. state: StepState,
  155. type: "text" | "reasoning",
  156. text: string,
  157. providerMetadata: ProviderMetadata | undefined,
  158. ) => {
  159. const last = state.assistantContent.at(-1)
  160. if (last?.type === type && text.length === 0) {
  161. state.assistantContent[state.assistantContent.length - 1] = {
  162. ...last,
  163. providerMetadata: mergeProviderMetadata(last.providerMetadata, providerMetadata),
  164. }
  165. return
  166. }
  167. if (last?.type === type && sameProviderMetadata(last.providerMetadata, providerMetadata)) {
  168. state.assistantContent[state.assistantContent.length - 1] = { ...last, text: `${last.text}${text}` }
  169. return
  170. }
  171. state.assistantContent.push({ type, text, providerMetadata })
  172. }
  173. const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<ToolResultValue> => {
  174. const tool = tools[call.name]
  175. if (!tool) return Effect.succeed({ type: "error" as const, value: `Unknown tool: ${call.name}` })
  176. if (!tool.execute)
  177. return Effect.succeed({ type: "error" as const, value: `Tool has no execute handler: ${call.name}` })
  178. return decodeAndExecute(tool, call.input).pipe(
  179. Effect.catchTag("LLM.ToolFailure", (failure) =>
  180. Effect.succeed({ type: "error" as const, value: failure.message } satisfies ToolResultValue),
  181. ),
  182. )
  183. }
  184. const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect<ToolResultValue, ToolFailure> =>
  185. tool._decode(input).pipe(
  186. Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
  187. Effect.flatMap((decoded) => tool.execute!(decoded)),
  188. Effect.flatMap((value) =>
  189. tool._encode(value).pipe(
  190. Effect.mapError(
  191. (error) =>
  192. new ToolFailure({
  193. message: `Tool returned an invalid value for its success schema: ${error.message}`,
  194. }),
  195. ),
  196. ),
  197. ),
  198. Effect.map((encoded): ToolResultValue => ({ type: "json", value: encoded })),
  199. )
  200. const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> =>
  201. result.type === "error"
  202. ? [
  203. LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }),
  204. LLMEvent.toolResult({ id: call.id, name: call.name, result }),
  205. ]
  206. : [LLMEvent.toolResult({ id: call.id, name: call.name, result })]
  207. const followUpRequest = (
  208. request: LLMRequest,
  209. state: StepState,
  210. dispatched: ReadonlyArray<readonly [ToolCallPart, ToolResultValue]>,
  211. ) =>
  212. LLMRequest.update(request, {
  213. messages: [
  214. ...request.messages,
  215. Message.assistant(state.assistantContent),
  216. ...dispatched.map(([call, result]) => Message.tool({ id: call.id, name: call.name, result })),
  217. ],
  218. })
  219. export const ToolRuntime = { stream, stepCountIs } as const