tool-runtime.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  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. Usage,
  16. } from "./schema"
  17. import { type AnyTool, type ExecutableTools, type Tools, toDefinitions } from "./tool"
  18. export interface RuntimeState {
  19. readonly step: number
  20. readonly request: LLMRequest
  21. }
  22. export type StopCondition = (state: RuntimeState) => boolean
  23. export type ToolExecution = "auto" | "none"
  24. interface RunOptionsBase {
  25. readonly request: LLMRequest
  26. readonly concurrency?: Concurrency
  27. readonly stopWhen?: StopCondition
  28. }
  29. export type RunOptions<T extends Tools> = RunOptionsAuto<T & ExecutableTools> | RunOptionsNone<T>
  30. export interface RunOptionsAuto<T extends ExecutableTools> extends RunOptionsBase {
  31. readonly request: LLMRequest
  32. readonly tools: T
  33. readonly toolExecution?: "auto"
  34. }
  35. export interface RunOptionsNone<T extends Tools> extends RunOptionsBase {
  36. readonly request: LLMRequest
  37. readonly tools: T
  38. /** Advertise tool schemas but leave model-emitted tool calls for the caller. */
  39. readonly toolExecution: "none"
  40. }
  41. export type StreamOptions<T extends Tools> = RunOptions<T> & {
  42. readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
  43. }
  44. export const stepCountIs =
  45. (count: number): StopCondition =>
  46. (state) =>
  47. state.step + 1 >= count
  48. /**
  49. * Run a model with typed tools. This helper owns tool orchestration, while the
  50. * caller supplies the actual model stream function. It can advertise schemas
  51. * only (`toolExecution: "none"`), execute one step, or continue model rounds
  52. * when `stopWhen` is provided.
  53. */
  54. export const stream = <T extends Tools>(options: StreamOptions<T>): Stream.Stream<LLMEvent, LLMError> => {
  55. const concurrency = options.concurrency ?? 10
  56. const tools = options.tools as Tools
  57. const runtimeTools = toDefinitions(tools)
  58. const runtimeToolNames = new Set(runtimeTools.map((tool) => tool.name))
  59. const initialRequest =
  60. runtimeTools.length === 0
  61. ? options.request
  62. : LLMRequest.update(options.request, {
  63. tools: [...options.request.tools.filter((tool) => !runtimeToolNames.has(tool.name)), ...runtimeTools],
  64. })
  65. const loop = (
  66. request: LLMRequest,
  67. step: number,
  68. usage: Usage | undefined,
  69. providerMetadata: ProviderMetadata | undefined,
  70. ): Stream.Stream<LLMEvent, LLMError> =>
  71. Stream.unwrap(
  72. Effect.gen(function* () {
  73. const state: StepState = {
  74. assistantContent: [],
  75. toolCalls: [],
  76. finishReason: undefined,
  77. usage: undefined,
  78. providerMetadata: undefined,
  79. }
  80. const modelStream = options
  81. .stream(request)
  82. .pipe(Stream.map((event) => indexStep(event, step)))
  83. .pipe(Stream.tap((event) => Effect.sync(() => accumulate(state, event))))
  84. .pipe(Stream.filter((event) => event.type !== "finish"))
  85. const continuation = Stream.unwrap(
  86. Effect.gen(function* () {
  87. const totalUsage = addUsage(usage, state.usage)
  88. const totalProviderMetadata = mergeProviderMetadata(providerMetadata, state.providerMetadata)
  89. const finishStream = Stream.fromIterable([
  90. LLMEvent.finish({
  91. reason: state.finishReason ?? "unknown",
  92. usage: totalUsage,
  93. providerMetadata: totalProviderMetadata,
  94. }),
  95. ])
  96. if (state.finishReason !== "tool-calls" || state.toolCalls.length === 0) return finishStream
  97. if (options.toolExecution === "none") return finishStream
  98. const dispatched = yield* Effect.forEach(
  99. state.toolCalls,
  100. (call) =>
  101. dispatch(tools, call).pipe(Effect.map((result) => [call, result.result, result.error] as const)),
  102. { concurrency },
  103. )
  104. const resultStream = Stream.fromIterable(
  105. dispatched.flatMap(([call, result, error]) => emitEvents(call, result, error)),
  106. )
  107. if (!options.stopWhen) return resultStream.pipe(Stream.concat(finishStream))
  108. if (options.stopWhen({ step, request })) return resultStream.pipe(Stream.concat(finishStream))
  109. return resultStream.pipe(
  110. Stream.concat(
  111. loop(
  112. followUpRequest(
  113. request,
  114. state,
  115. dispatched.map(([call, result]) => [call, result] as const),
  116. ),
  117. step + 1,
  118. totalUsage,
  119. totalProviderMetadata,
  120. ),
  121. ),
  122. )
  123. }),
  124. )
  125. return modelStream.pipe(Stream.concat(continuation))
  126. }),
  127. )
  128. return loop(initialRequest, 0, undefined, undefined)
  129. }
  130. const indexStep = (event: LLMEvent, index: number): LLMEvent => {
  131. if (event.type === "step-start") return LLMEvent.stepStart({ index })
  132. if (event.type === "step-finish") return LLMEvent.stepFinish({ ...event, index })
  133. return event
  134. }
  135. interface StepState {
  136. assistantContent: ContentPart[]
  137. toolCalls: ToolCallPart[]
  138. finishReason: FinishReason | undefined
  139. usage: Usage | undefined
  140. providerMetadata: ProviderMetadata | undefined
  141. }
  142. const accumulate = (state: StepState, event: LLMEvent) => {
  143. if (event.type === "text-delta") {
  144. appendStreamingText(state, "text", event.text, undefined)
  145. return
  146. }
  147. if (event.type === "reasoning-delta") {
  148. appendStreamingText(state, "reasoning", event.text, undefined)
  149. return
  150. }
  151. if (event.type === "reasoning-end") {
  152. appendStreamingText(state, "reasoning", "", event.providerMetadata)
  153. return
  154. }
  155. if (event.type === "text-end") {
  156. appendStreamingText(state, "text", "", event.providerMetadata)
  157. return
  158. }
  159. if (event.type === "tool-call") {
  160. const part = ToolCallPart.make({
  161. id: event.id,
  162. name: event.name,
  163. input: event.input,
  164. providerExecuted: event.providerExecuted,
  165. providerMetadata: event.providerMetadata,
  166. })
  167. state.assistantContent.push(part)
  168. if (!event.providerExecuted) state.toolCalls.push(part)
  169. return
  170. }
  171. if (event.type === "tool-result" && event.providerExecuted) {
  172. state.assistantContent.push(
  173. ToolResultPart.make({
  174. id: event.id,
  175. name: event.name,
  176. result: event.result,
  177. providerExecuted: true,
  178. providerMetadata: event.providerMetadata,
  179. }),
  180. )
  181. return
  182. }
  183. if (event.type === "step-finish") {
  184. state.finishReason = event.reason === "stop" && state.toolCalls.length > 0 ? "tool-calls" : event.reason
  185. state.usage = addUsage(state.usage, event.usage)
  186. state.providerMetadata = mergeProviderMetadata(state.providerMetadata, event.providerMetadata)
  187. return
  188. }
  189. if (event.type === "finish") {
  190. state.finishReason ??= event.reason
  191. state.usage ??= event.usage
  192. state.providerMetadata = mergeProviderMetadata(state.providerMetadata, event.providerMetadata)
  193. }
  194. }
  195. const addUsage = (left: Usage | undefined, right: Usage | undefined) => {
  196. if (!left) return right
  197. if (!right) return left
  198. type UsageKey =
  199. | "inputTokens"
  200. | "outputTokens"
  201. | "nonCachedInputTokens"
  202. | "cacheReadInputTokens"
  203. | "cacheWriteInputTokens"
  204. | "reasoningTokens"
  205. | "totalTokens"
  206. const sum = (key: UsageKey) =>
  207. left[key] === undefined && right[key] === undefined ? undefined : (left[key] ?? 0) + (right[key] ?? 0)
  208. return new Usage({
  209. inputTokens: sum("inputTokens"),
  210. outputTokens: sum("outputTokens"),
  211. nonCachedInputTokens: sum("nonCachedInputTokens"),
  212. cacheReadInputTokens: sum("cacheReadInputTokens"),
  213. cacheWriteInputTokens: sum("cacheWriteInputTokens"),
  214. reasoningTokens: sum("reasoningTokens"),
  215. totalTokens: sum("totalTokens"),
  216. providerMetadata: mergeProviderMetadata(left.providerMetadata, right.providerMetadata),
  217. })
  218. }
  219. const sameProviderMetadata = (left: ProviderMetadata | undefined, right: ProviderMetadata | undefined) =>
  220. left === right || JSON.stringify(left) === JSON.stringify(right)
  221. const mergeProviderMetadata = (left: ProviderMetadata | undefined, right: ProviderMetadata | undefined) => {
  222. if (!left) return right
  223. if (!right) return left
  224. return Object.fromEntries(
  225. Array.from(new Set([...Object.keys(left), ...Object.keys(right)])).map((provider) => [
  226. provider,
  227. { ...left[provider], ...right[provider] },
  228. ]),
  229. )
  230. }
  231. const appendStreamingText = (
  232. state: StepState,
  233. type: "text" | "reasoning",
  234. text: string,
  235. providerMetadata: ProviderMetadata | undefined,
  236. ) => {
  237. const last = state.assistantContent.at(-1)
  238. if (last?.type === type && text.length === 0) {
  239. state.assistantContent[state.assistantContent.length - 1] = {
  240. ...last,
  241. providerMetadata: mergeProviderMetadata(last.providerMetadata, providerMetadata),
  242. }
  243. return
  244. }
  245. if (last?.type === type && sameProviderMetadata(last.providerMetadata, providerMetadata)) {
  246. state.assistantContent[state.assistantContent.length - 1] = { ...last, text: `${last.text}${text}` }
  247. return
  248. }
  249. state.assistantContent.push({ type, text, providerMetadata })
  250. }
  251. const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<{ result: ToolResultValue; error?: unknown }> => {
  252. const tool = tools[call.name]
  253. if (!tool) return Effect.succeed({ result: { type: "error" as const, value: `Unknown tool: ${call.name}` } })
  254. if (!tool.execute)
  255. return Effect.succeed({ result: { type: "error" as const, value: `Tool has no execute handler: ${call.name}` } })
  256. return decodeAndExecute(tool, call).pipe(
  257. Effect.catchTag("LLM.ToolFailure", (failure) =>
  258. Effect.succeed({
  259. result: { type: "error" as const, value: failure.message } satisfies ToolResultValue,
  260. error: failure.error,
  261. }),
  262. ),
  263. Effect.map((result) => ("result" in result ? result : { result })),
  264. )
  265. }
  266. const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<ToolResultValue, ToolFailure> =>
  267. tool._decode(call.input).pipe(
  268. Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
  269. Effect.flatMap((decoded) => tool.execute!(decoded, { id: call.id, name: call.name })),
  270. Effect.flatMap((value) =>
  271. tool._encode(value).pipe(
  272. Effect.mapError(
  273. (error) =>
  274. new ToolFailure({
  275. message: `Tool returned an invalid value for its success schema: ${error.message}`,
  276. }),
  277. ),
  278. ),
  279. ),
  280. Effect.map((encoded): ToolResultValue => ({ type: "json", value: encoded })),
  281. )
  282. const emitEvents = (call: ToolCallPart, result: ToolResultValue, error: unknown): ReadonlyArray<LLMEvent> =>
  283. result.type === "error"
  284. ? [
  285. LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value), error }),
  286. LLMEvent.toolResult({ id: call.id, name: call.name, result }),
  287. ]
  288. : [LLMEvent.toolResult({ id: call.id, name: call.name, result })]
  289. const followUpRequest = (
  290. request: LLMRequest,
  291. state: StepState,
  292. dispatched: ReadonlyArray<readonly [ToolCallPart, ToolResultValue]>,
  293. ) =>
  294. LLMRequest.update(request, {
  295. messages: [
  296. ...request.messages,
  297. Message.assistant(state.assistantContent),
  298. ...dispatched.map(([call, result]) => Message.tool({ id: call.id, name: call.name, result })),
  299. ],
  300. })
  301. export const ToolRuntime = { stream, stepCountIs } as const