tool-runtime.test.ts 21 KB

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