tool-runtime.ts 11 KB

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