tool-runtime.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Schema, Stream } from "effect"
  3. import { GenerationOptions, LLM, LLMEvent, LLMRequest, LLMResponse, ToolChoice } 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: GenerationOptions.make({ maxTokens: 50 }),
  73. toolChoice: ToolChoice.make("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([
  282. "step-start",
  283. "text-start",
  284. "text-delta",
  285. "text-end",
  286. "step-finish",
  287. "request-finish",
  288. ])
  289. expect(LLMResponse.text({ events })).toBe("Done.")
  290. }),
  291. )
  292. it.effect("respects maxSteps and stops the loop", () =>
  293. Effect.gen(function* () {
  294. // Every script entry asks for another tool call. With maxSteps: 2 the
  295. // runtime should run at most two model rounds and then exit even though
  296. // the model still wants to keep going.
  297. const toolCallStep = sseEvents(
  298. toolCallChunk("call_x", "get_weather", '{"city":"Paris"}'),
  299. finishChunk("tool_calls"),
  300. )
  301. const layer = scriptedResponses([toolCallStep, toolCallStep, toolCallStep])
  302. const events = Array.from(
  303. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather }, maxSteps: 2 }).pipe(
  304. Stream.runCollect,
  305. Effect.provide(layer),
  306. ),
  307. )
  308. expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(2)
  309. }),
  310. )
  311. it.effect("stops follow-up when stopWhen returns true after the first step", () =>
  312. Effect.gen(function* () {
  313. const layer = scriptedResponses([
  314. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  315. sseEvents(deltaChunk({ role: "assistant", content: "Should not run." }), finishChunk("stop")),
  316. ])
  317. const events = Array.from(
  318. yield* TestToolRuntime.runTools({
  319. request: baseRequest,
  320. tools: { get_weather },
  321. stopWhen: (state) => state.step >= 0,
  322. }).pipe(Stream.runCollect, Effect.provide(layer)),
  323. )
  324. expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1)
  325. expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" })
  326. }),
  327. )
  328. it.effect("does not dispatch provider-executed tool calls", () =>
  329. Effect.gen(function* () {
  330. let streams = 0
  331. const layer = dynamicResponse((input) =>
  332. Effect.sync(() => {
  333. streams++
  334. return input.respond(
  335. sseEvents(
  336. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  337. {
  338. type: "content_block_start",
  339. index: 0,
  340. content_block: { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search" },
  341. },
  342. {
  343. type: "content_block_delta",
  344. index: 0,
  345. delta: { type: "input_json_delta", partial_json: '{"query":"x"}' },
  346. },
  347. { type: "content_block_stop", index: 0 },
  348. {
  349. type: "content_block_start",
  350. index: 1,
  351. content_block: {
  352. type: "web_search_tool_result",
  353. tool_use_id: "srvtoolu_abc",
  354. content: [{ type: "web_search_result", url: "https://example.com", title: "Example" }],
  355. },
  356. },
  357. { type: "content_block_stop", index: 1 },
  358. { type: "content_block_start", index: 2, content_block: { type: "text", text: "" } },
  359. { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } },
  360. { type: "content_block_stop", index: 2 },
  361. { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
  362. ),
  363. { headers: { "content-type": "text/event-stream" } },
  364. )
  365. }),
  366. )
  367. const events = Array.from(
  368. yield* TestToolRuntime.runTools({
  369. request: LLM.updateRequest(baseRequest, {
  370. model: AnthropicMessages.model({ id: "claude-sonnet-4-5", apiKey: "test" }),
  371. }),
  372. tools: {},
  373. }).pipe(Stream.runCollect, Effect.provide(layer)),
  374. )
  375. expect(streams).toBe(1)
  376. expect(events.find(LLMEvent.is.toolError)).toBeUndefined()
  377. expect(events.filter(LLMEvent.is.toolCall)).toEqual([
  378. {
  379. type: "tool-call",
  380. id: "srvtoolu_abc",
  381. name: "web_search",
  382. input: { query: "x" },
  383. providerExecuted: true,
  384. },
  385. ])
  386. expect(LLMResponse.text({ events })).toBe("Done.")
  387. }),
  388. )
  389. it.effect("dispatches multiple tool calls in one step concurrently", () =>
  390. Effect.gen(function* () {
  391. const layer = scriptedResponses([
  392. sseEvents(
  393. deltaChunk({
  394. role: "assistant",
  395. tool_calls: [
  396. { index: 0, id: "c1", function: { name: "get_weather", arguments: '{"city":"Paris"}' } },
  397. { index: 1, id: "c2", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } },
  398. ],
  399. }),
  400. finishChunk("tool_calls"),
  401. ),
  402. sseEvents(deltaChunk({ role: "assistant", content: "Both done." }), finishChunk("stop")),
  403. ])
  404. const events = Array.from(
  405. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  406. Stream.runCollect,
  407. Effect.provide(layer),
  408. ),
  409. )
  410. const results = events.filter(LLMEvent.is.toolResult)
  411. expect(results).toHaveLength(2)
  412. expect(results.map((event) => event.id).toSorted()).toEqual(["c1", "c2"])
  413. }),
  414. )
  415. })