tool-runtime.test.ts 21 KB

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