tool-runtime.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Schema, Stream } from "effect"
  3. import { LLM, LLMEvent, LLMRequest, LLMResponse } from "../src"
  4. import { LLMClient } from "../src/route"
  5. import * as AnthropicMessages from "../src/protocols/anthropic-messages"
  6. import * as OpenAIChat from "../src/protocols/openai-chat"
  7. import { tool, ToolFailure } from "../src/tool"
  8. import { it } from "./lib/effect"
  9. import * as TestToolRuntime from "./lib/tool-runtime"
  10. import { dynamicResponse, scriptedResponses } from "./lib/http"
  11. import { deltaChunk, finishChunk, toolCallChunk } from "./lib/openai-chunks"
  12. import { sseEvents } from "./lib/sse"
  13. const model = OpenAIChat.model({
  14. id: "gpt-4o-mini",
  15. baseURL: "https://api.openai.test/v1/",
  16. headers: { authorization: "Bearer test" },
  17. })
  18. const Json = Schema.fromJsonString(Schema.Unknown)
  19. const decodeJson = Schema.decodeUnknownSync(Json)
  20. const baseRequest = LLM.request({
  21. id: "req_1",
  22. model,
  23. prompt: "Use the tool.",
  24. })
  25. const get_weather = tool({
  26. description: "Get current weather for a city.",
  27. parameters: Schema.Struct({ city: Schema.String }),
  28. success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
  29. execute: ({ city }) =>
  30. Effect.gen(function* () {
  31. if (city === "FAIL") return yield* new ToolFailure({ message: `Weather lookup failed for ${city}` })
  32. return { temperature: 22, condition: "sunny" }
  33. }),
  34. })
  35. const schema_only_weather = tool({
  36. description: "Get current weather for a city.",
  37. parameters: Schema.Struct({ city: Schema.String }),
  38. success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
  39. })
  40. describe("LLMClient tools", () => {
  41. it.effect("uses the registered model route when adding runtime tools", () =>
  42. Effect.gen(function* () {
  43. const layer = scriptedResponses([
  44. sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
  45. ])
  46. const events = Array.from(
  47. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  48. Stream.runCollect,
  49. Effect.provide(layer),
  50. ),
  51. )
  52. expect(LLMResponse.text({ events })).toBe("Done.")
  53. }),
  54. )
  55. it.effect("sends tool-call history and request options on the follow-up request", () =>
  56. Effect.gen(function* () {
  57. const bodies: unknown[] = []
  58. const responses = [
  59. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  60. sseEvents(deltaChunk({ role: "assistant", content: "It's sunny in Paris." }), finishChunk("stop")),
  61. ]
  62. const layer = dynamicResponse((input) =>
  63. Effect.sync(() => {
  64. bodies.push(decodeJson(input.text))
  65. return input.respond(responses[bodies.length - 1] ?? responses[responses.length - 1], {
  66. headers: { "content-type": "text/event-stream" },
  67. })
  68. }),
  69. )
  70. yield* TestToolRuntime.runTools({
  71. request: LLMRequest.update(baseRequest, {
  72. generation: LLM.generation({ maxTokens: 50 }),
  73. toolChoice: LLM.toolChoice("auto"),
  74. }),
  75. tools: { get_weather },
  76. }).pipe(Stream.runCollect, Effect.provide(layer))
  77. const second = bodies[1] as {
  78. readonly messages?: ReadonlyArray<Record<string, unknown>>
  79. readonly tools?: ReadonlyArray<unknown>
  80. readonly tool_choice?: unknown
  81. readonly max_tokens?: unknown
  82. }
  83. expect(second.max_tokens).toBe(50)
  84. expect(second.tool_choice).toBe("auto")
  85. expect(second.tools).toHaveLength(1)
  86. expect(second.messages?.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
  87. expect(second.messages?.[1]).toMatchObject({
  88. role: "assistant",
  89. content: null,
  90. tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather" } }],
  91. })
  92. expect(second.messages?.[2]).toMatchObject({
  93. role: "tool",
  94. tool_call_id: "call_1",
  95. content: '{"temperature":22,"condition":"sunny"}',
  96. })
  97. }),
  98. )
  99. it.effect("dispatches a tool call, appends results, and resumes streaming", () =>
  100. Effect.gen(function* () {
  101. const layer = scriptedResponses([
  102. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  103. sseEvents(deltaChunk({ role: "assistant", content: "It's sunny in Paris." }), finishChunk("stop")),
  104. ])
  105. const events = Array.from(
  106. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  107. Stream.runCollect,
  108. Effect.provide(layer),
  109. ),
  110. )
  111. const result = events.find(LLMEvent.is.toolResult)
  112. expect(result).toMatchObject({
  113. type: "tool-result",
  114. id: "call_1",
  115. name: "get_weather",
  116. result: { type: "json", value: { temperature: 22, condition: "sunny" } },
  117. })
  118. expect(events.at(-1)?.type).toBe("request-finish")
  119. expect(LLMResponse.text({ events })).toBe("It's sunny in Paris.")
  120. }),
  121. )
  122. it.effect("executes tool calls for one step without looping by default", () =>
  123. Effect.gen(function* () {
  124. const layer = scriptedResponses([
  125. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  126. sseEvents(deltaChunk({ role: "assistant", content: "Should not run." }), finishChunk("stop")),
  127. ])
  128. const events = Array.from(
  129. yield* LLMClient.stream({ request: baseRequest, tools: { get_weather } }).pipe(
  130. Stream.runCollect,
  131. Effect.provide(layer),
  132. ),
  133. )
  134. expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1)
  135. expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" })
  136. }),
  137. )
  138. it.effect("can expose tool schemas without executing tool calls", () =>
  139. Effect.gen(function* () {
  140. const layer = scriptedResponses([
  141. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  142. ])
  143. const events = Array.from(
  144. yield* LLMClient.stream({
  145. request: baseRequest,
  146. tools: { get_weather: schema_only_weather },
  147. toolExecution: "none",
  148. }).pipe(Stream.runCollect, Effect.provide(layer)),
  149. )
  150. expect(events.find(LLMEvent.is.toolCall)).toMatchObject({ type: "tool-call", id: "call_1" })
  151. expect(events.find(LLMEvent.is.toolResult)).toBeUndefined()
  152. }),
  153. )
  154. it.effect("preserves provider metadata when folding streamed assistant content into follow-up history", () =>
  155. Effect.gen(function* () {
  156. const bodies: unknown[] = []
  157. const layer = dynamicResponse((input) =>
  158. Effect.sync(() => {
  159. bodies.push(decodeJson(input.text))
  160. return input.respond(
  161. bodies.length === 1
  162. ? sseEvents(
  163. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  164. { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } },
  165. { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "thinking" } },
  166. { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } },
  167. { type: "content_block_stop", index: 0 },
  168. {
  169. type: "content_block_start",
  170. index: 1,
  171. content_block: { type: "tool_use", id: "call_1", name: "get_weather" },
  172. },
  173. {
  174. type: "content_block_delta",
  175. index: 1,
  176. delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' },
  177. },
  178. { type: "content_block_stop", index: 1 },
  179. { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } },
  180. )
  181. : sseEvents(
  182. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  183. { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
  184. { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } },
  185. { type: "content_block_stop", index: 0 },
  186. { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
  187. ),
  188. { headers: { "content-type": "text/event-stream" } },
  189. )
  190. }),
  191. )
  192. yield* TestToolRuntime.runTools({
  193. request: LLM.updateRequest(baseRequest, {
  194. model: AnthropicMessages.model({ id: "claude-sonnet-4-5", apiKey: "test" }),
  195. }),
  196. tools: { get_weather },
  197. }).pipe(Stream.runCollect, Effect.provide(layer))
  198. expect(bodies[1]).toMatchObject({
  199. messages: [
  200. { role: "user" },
  201. {
  202. role: "assistant",
  203. content: [
  204. { type: "thinking", thinking: "thinking", signature: "sig_1" },
  205. { type: "tool_use", id: "call_1", name: "get_weather", input: { city: "Paris" } },
  206. ],
  207. },
  208. { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1" }] },
  209. ],
  210. })
  211. }),
  212. )
  213. it.effect("emits tool-error for unknown tools so the model can self-correct", () =>
  214. Effect.gen(function* () {
  215. const layer = scriptedResponses([
  216. sseEvents(toolCallChunk("call_1", "missing_tool", "{}"), finishChunk("tool_calls")),
  217. sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")),
  218. ])
  219. const events = Array.from(
  220. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  221. Stream.runCollect,
  222. Effect.provide(layer),
  223. ),
  224. )
  225. const toolError = events.find(LLMEvent.is.toolError)
  226. expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" })
  227. expect(toolError?.message).toContain("Unknown tool")
  228. expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
  229. type: "tool-result",
  230. id: "call_1",
  231. name: "missing_tool",
  232. result: { type: "error", value: "Unknown tool: missing_tool" },
  233. })
  234. }),
  235. )
  236. it.effect("emits tool-error when the LLM input fails the parameters schema", () =>
  237. Effect.gen(function* () {
  238. const layer = scriptedResponses([
  239. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":42}'), finishChunk("tool_calls")),
  240. sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
  241. ])
  242. const events = Array.from(
  243. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  244. Stream.runCollect,
  245. Effect.provide(layer),
  246. ),
  247. )
  248. const toolError = events.find(LLMEvent.is.toolError)
  249. expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
  250. expect(toolError?.message).toContain("Invalid tool input")
  251. }),
  252. )
  253. it.effect("emits tool-error when the handler returns a ToolFailure", () =>
  254. Effect.gen(function* () {
  255. const layer = scriptedResponses([
  256. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"FAIL"}'), finishChunk("tool_calls")),
  257. sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")),
  258. ])
  259. const events = Array.from(
  260. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  261. Stream.runCollect,
  262. Effect.provide(layer),
  263. ),
  264. )
  265. const toolError = events.find(LLMEvent.is.toolError)
  266. expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
  267. expect(toolError?.message).toBe("Weather lookup failed for FAIL")
  268. }),
  269. )
  270. it.effect("stops when the model finishes without requesting more tools", () =>
  271. Effect.gen(function* () {
  272. const layer = scriptedResponses([
  273. sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
  274. ])
  275. const events = Array.from(
  276. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  277. Stream.runCollect,
  278. Effect.provide(layer),
  279. ),
  280. )
  281. expect(events.map((event) => event.type)).toEqual(["text-delta", "request-finish"])
  282. expect(LLMResponse.text({ events })).toBe("Done.")
  283. }),
  284. )
  285. it.effect("respects maxSteps and stops the loop", () =>
  286. Effect.gen(function* () {
  287. // Every script entry asks for another tool call. With maxSteps: 2 the
  288. // runtime should run at most two model rounds and then exit even though
  289. // the model still wants to keep going.
  290. const toolCallStep = sseEvents(
  291. toolCallChunk("call_x", "get_weather", '{"city":"Paris"}'),
  292. finishChunk("tool_calls"),
  293. )
  294. const layer = scriptedResponses([toolCallStep, toolCallStep, toolCallStep])
  295. const events = Array.from(
  296. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather }, maxSteps: 2 }).pipe(
  297. Stream.runCollect,
  298. Effect.provide(layer),
  299. ),
  300. )
  301. expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(2)
  302. }),
  303. )
  304. it.effect("stops follow-up when stopWhen returns true after the first step", () =>
  305. Effect.gen(function* () {
  306. const layer = scriptedResponses([
  307. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  308. sseEvents(deltaChunk({ role: "assistant", content: "Should not run." }), finishChunk("stop")),
  309. ])
  310. const events = Array.from(
  311. yield* TestToolRuntime.runTools({
  312. request: baseRequest,
  313. tools: { get_weather },
  314. stopWhen: (state) => state.step >= 0,
  315. }).pipe(Stream.runCollect, Effect.provide(layer)),
  316. )
  317. expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1)
  318. expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" })
  319. }),
  320. )
  321. it.effect("does not dispatch provider-executed tool calls", () =>
  322. Effect.gen(function* () {
  323. let streams = 0
  324. const layer = dynamicResponse((input) =>
  325. Effect.sync(() => {
  326. streams++
  327. return input.respond(
  328. sseEvents(
  329. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  330. {
  331. type: "content_block_start",
  332. index: 0,
  333. content_block: { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search" },
  334. },
  335. {
  336. type: "content_block_delta",
  337. index: 0,
  338. delta: { type: "input_json_delta", partial_json: '{"query":"x"}' },
  339. },
  340. { type: "content_block_stop", index: 0 },
  341. {
  342. type: "content_block_start",
  343. index: 1,
  344. content_block: {
  345. type: "web_search_tool_result",
  346. tool_use_id: "srvtoolu_abc",
  347. content: [{ type: "web_search_result", url: "https://example.com", title: "Example" }],
  348. },
  349. },
  350. { type: "content_block_stop", index: 1 },
  351. { type: "content_block_start", index: 2, content_block: { type: "text", text: "" } },
  352. { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } },
  353. { type: "content_block_stop", index: 2 },
  354. { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
  355. ),
  356. { headers: { "content-type": "text/event-stream" } },
  357. )
  358. }),
  359. )
  360. const events = Array.from(
  361. yield* TestToolRuntime.runTools({
  362. request: LLM.updateRequest(baseRequest, {
  363. model: AnthropicMessages.model({ id: "claude-sonnet-4-5", apiKey: "test" }),
  364. }),
  365. tools: {},
  366. }).pipe(Stream.runCollect, Effect.provide(layer)),
  367. )
  368. expect(streams).toBe(1)
  369. expect(events.find(LLMEvent.is.toolError)).toBeUndefined()
  370. expect(events.filter(LLMEvent.is.toolCall)).toEqual([
  371. {
  372. type: "tool-call",
  373. id: "srvtoolu_abc",
  374. name: "web_search",
  375. input: { query: "x" },
  376. providerExecuted: true,
  377. },
  378. ])
  379. expect(LLMResponse.text({ events })).toBe("Done.")
  380. }),
  381. )
  382. it.effect("dispatches multiple tool calls in one step concurrently", () =>
  383. Effect.gen(function* () {
  384. const layer = scriptedResponses([
  385. sseEvents(
  386. deltaChunk({
  387. role: "assistant",
  388. tool_calls: [
  389. { index: 0, id: "c1", function: { name: "get_weather", arguments: '{"city":"Paris"}' } },
  390. { index: 1, id: "c2", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } },
  391. ],
  392. }),
  393. finishChunk("tool_calls"),
  394. ),
  395. sseEvents(deltaChunk({ role: "assistant", content: "Both done." }), finishChunk("stop")),
  396. ])
  397. const events = Array.from(
  398. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  399. Stream.runCollect,
  400. Effect.provide(layer),
  401. ),
  402. )
  403. const results = events.filter(LLMEvent.is.toolResult)
  404. expect(results).toHaveLength(2)
  405. expect(results.map((event) => event.id).toSorted()).toEqual(["c1", "c2"])
  406. }),
  407. )
  408. })