tool-runtime.test.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Schema, Stream } from "effect"
  3. import {
  4. GenerationOptions,
  5. LLM,
  6. LLMEvent,
  7. LLMRequest,
  8. LLMResponse,
  9. ToolChoice,
  10. ToolContent,
  11. ToolOutput,
  12. toDefinitions,
  13. } from "../src"
  14. import { Auth, LLMClient } from "../src/route"
  15. import * as AnthropicMessages from "../src/protocols/anthropic-messages"
  16. import * as OpenAIChat from "../src/protocols/openai-chat"
  17. import * as OpenAIResponses from "../src/protocols/openai-responses"
  18. import { Tool, ToolFailure, type ToolExecuteContext } from "../src/tool"
  19. import { ToolRuntime } from "../src/tool-runtime"
  20. import { it } from "./lib/effect"
  21. import * as TestToolRuntime from "./lib/tool-runtime"
  22. import { dynamicResponse, scriptedResponses } from "./lib/http"
  23. import { deltaChunk, finishChunk, toolCallChunk } from "./lib/openai-chunks"
  24. import { sseEvents } from "./lib/sse"
  25. const model = OpenAIChat.route
  26. .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
  27. .model({ id: "gpt-4o-mini" })
  28. const Json = Schema.fromJsonString(Schema.Unknown)
  29. const decodeJson = Schema.decodeUnknownSync(Json)
  30. const baseRequest = LLM.request({
  31. id: "req_1",
  32. model,
  33. prompt: "Use the tool.",
  34. })
  35. const weatherFailureCause = new Error("weather lookup denied")
  36. const get_weather = Tool.make({
  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. execute: ({ city }) =>
  41. Effect.gen(function* () {
  42. if (city === "FAIL")
  43. return yield* new ToolFailure({ message: `Weather lookup failed for ${city}`, error: weatherFailureCause })
  44. return { temperature: 22, condition: "sunny" }
  45. }),
  46. })
  47. const schema_only_weather = Tool.make({
  48. description: "Get current weather for a city.",
  49. parameters: Schema.Struct({ city: Schema.String }),
  50. success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
  51. })
  52. describe("LLMClient tools", () => {
  53. it.effect("uses the registered model route when adding runtime tools", () =>
  54. Effect.gen(function* () {
  55. const layer = scriptedResponses([
  56. sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
  57. ])
  58. const events = Array.from(
  59. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  60. Stream.runCollect,
  61. Effect.provide(layer),
  62. ),
  63. )
  64. expect(LLMResponse.text({ events })).toBe("Done.")
  65. }),
  66. )
  67. it.effect("sends tool-call history and request options on the follow-up request", () =>
  68. Effect.gen(function* () {
  69. const bodies: unknown[] = []
  70. const responses = [
  71. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  72. sseEvents(deltaChunk({ role: "assistant", content: "It's sunny in Paris." }), finishChunk("stop")),
  73. ]
  74. const layer = dynamicResponse((input) =>
  75. Effect.sync(() => {
  76. bodies.push(decodeJson(input.text))
  77. return input.respond(responses[bodies.length - 1] ?? responses[responses.length - 1], {
  78. headers: { "content-type": "text/event-stream" },
  79. })
  80. }),
  81. )
  82. yield* TestToolRuntime.runTools({
  83. request: LLMRequest.update(baseRequest, {
  84. generation: GenerationOptions.make({ maxTokens: 50 }),
  85. toolChoice: ToolChoice.make("auto"),
  86. }),
  87. tools: { get_weather },
  88. }).pipe(Stream.runCollect, Effect.provide(layer))
  89. const second = bodies[1]
  90. if (!second || typeof second !== "object") throw new Error("Expected second request body")
  91. const messages = Reflect.get(second, "messages")
  92. const tools = Reflect.get(second, "tools")
  93. expect(Reflect.get(second, "max_tokens")).toBe(50)
  94. expect(Reflect.get(second, "tool_choice")).toBe("auto")
  95. expect(tools).toHaveLength(1)
  96. expect(
  97. Array.isArray(messages)
  98. ? messages.map((message) =>
  99. message && typeof message === "object" ? Reflect.get(message, "role") : undefined,
  100. )
  101. : undefined,
  102. ).toEqual(["user", "assistant", "tool"])
  103. expect(Array.isArray(messages) ? messages[1] : undefined).toMatchObject({
  104. role: "assistant",
  105. content: null,
  106. tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather" } }],
  107. })
  108. expect(Array.isArray(messages) ? messages[2] : undefined).toMatchObject({
  109. role: "tool",
  110. tool_call_id: "call_1",
  111. content: '{"temperature":22,"condition":"sunny"}',
  112. })
  113. }),
  114. )
  115. it.effect("dispatches a tool call, appends results, and resumes streaming", () =>
  116. Effect.gen(function* () {
  117. const layer = scriptedResponses([
  118. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  119. sseEvents(deltaChunk({ role: "assistant", content: "It's sunny in Paris." }), finishChunk("stop")),
  120. ])
  121. const events = Array.from(
  122. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  123. Stream.runCollect,
  124. Effect.provide(layer),
  125. ),
  126. )
  127. const result = events.find(LLMEvent.is.toolResult)
  128. expect(result).toMatchObject({
  129. type: "tool-result",
  130. id: "call_1",
  131. name: "get_weather",
  132. result: { type: "json", value: { temperature: 22, condition: "sunny" } },
  133. })
  134. expect(events.at(-1)?.type).toBe("finish")
  135. expect(LLMResponse.text({ events })).toBe("It's sunny in Paris.")
  136. }),
  137. )
  138. it.effect("projects encoded typed tool success into canonical model content", () =>
  139. Effect.gen(function* () {
  140. const calls: unknown[] = []
  141. const projected = Tool.make({
  142. description: "Project an encoded success.",
  143. parameters: Schema.Struct({ prefix: Schema.String }),
  144. success: Schema.Struct({ count: Schema.NumberFromString }),
  145. execute: () => Effect.succeed({ count: 2 }),
  146. toModelOutput: (input) => {
  147. calls.push(input)
  148. return [{ type: "text", text: `${input.parameters.prefix}:${input.output.count}` }]
  149. },
  150. })
  151. const dispatched = yield* ToolRuntime.dispatch(
  152. { projected },
  153. LLMEvent.toolCall({ id: "call_projected", name: "projected", input: { prefix: "count" } }),
  154. )
  155. expect(calls).toEqual([{ callID: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
  156. expect(dispatched.result).toEqual({ type: "text", value: "count:2" })
  157. expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] })
  158. expect(dispatched.events).toEqual([
  159. LLMEvent.toolResult({
  160. id: "call_projected",
  161. name: "projected",
  162. result: { type: "text", value: "count:2" },
  163. output: { structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] },
  164. }),
  165. ])
  166. }),
  167. )
  168. it.effect("uses the narrow default projection for encoded typed success", () =>
  169. Effect.gen(function* () {
  170. const text = Tool.make({
  171. description: "Return text.",
  172. parameters: Schema.Struct({}),
  173. success: Schema.String,
  174. execute: () => Effect.succeed("hello"),
  175. })
  176. const json = Tool.make({
  177. description: "Return JSON.",
  178. parameters: Schema.Struct({}),
  179. success: Schema.Struct({ ok: Schema.Boolean }),
  180. execute: () => Effect.succeed({ ok: true }),
  181. })
  182. expect(
  183. (yield* ToolRuntime.dispatch({ text }, LLMEvent.toolCall({ id: "call_text", name: "text", input: {} }))).output,
  184. ).toEqual({ structured: "hello", content: [{ type: "text", text: "hello" }] })
  185. expect(
  186. (yield* ToolRuntime.dispatch({ json }, LLMEvent.toolCall({ id: "call_json", name: "json", input: {} }))).output,
  187. ).toEqual({ structured: { ok: true }, content: [] })
  188. }),
  189. )
  190. it.effect("can retain model media while redacting duplicated structured payloads", () =>
  191. Effect.gen(function* () {
  192. const image = Tool.make({
  193. description: "Return an image.",
  194. parameters: Schema.Struct({}),
  195. success: Schema.Struct({ mime: Schema.String, data: Schema.String }),
  196. execute: () => Effect.succeed({ mime: "image/png", data: "AAECAw==" }),
  197. toStructuredOutput: (output) => ({ mime: output.mime }),
  198. toModelOutput: ({ output }) => [
  199. { type: "file", uri: `data:${output.mime};base64,${output.data}`, mime: output.mime },
  200. ],
  201. })
  202. const dispatched = yield* ToolRuntime.dispatch(
  203. { image },
  204. LLMEvent.toolCall({ id: "call_image", name: "image", input: {} }),
  205. )
  206. expect(dispatched.output).toEqual({
  207. structured: { mime: "image/png" },
  208. content: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }],
  209. })
  210. }),
  211. )
  212. it.effect("models canonical tool files with URIs", () =>
  213. Effect.sync(() => {
  214. const decode = Schema.decodeUnknownSync(ToolContent)
  215. expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
  216. type: "file",
  217. uri: "data:image/png;base64,AAAA",
  218. mime: "image/png",
  219. })
  220. expect(decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" })).toEqual({
  221. type: "file",
  222. uri: "https://example.test/image.png",
  223. mime: "image/png",
  224. })
  225. expect(decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" })).toEqual({
  226. type: "file",
  227. uri: "file:///tmp/image.png",
  228. mime: "image/png",
  229. })
  230. }),
  231. )
  232. it.effect("preserves canonical tool file URIs", () =>
  233. Effect.sync(() => {
  234. expect(
  235. ToolOutput.toResultValue(
  236. ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]),
  237. ),
  238. ).toEqual({
  239. type: "content",
  240. value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
  241. })
  242. expect(
  243. ToolOutput.toResultValue(
  244. ToolOutput.make({}, [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }]),
  245. ),
  246. ).toEqual({
  247. type: "content",
  248. value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
  249. })
  250. expect(
  251. ToolOutput.toResultValue(
  252. ToolOutput.make({}, [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }]),
  253. ),
  254. ).toEqual({
  255. type: "content",
  256. value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }],
  257. })
  258. expect(
  259. ToolOutput.fromResultValue({
  260. type: "content",
  261. value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
  262. }),
  263. ).toEqual({
  264. structured: {},
  265. content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
  266. })
  267. }),
  268. )
  269. it.effect("settles projected URL files as canonical tool results", () =>
  270. Effect.gen(function* () {
  271. const remote = Tool.make({
  272. description: "Return a remote file.",
  273. parameters: Schema.Struct({}),
  274. success: Schema.Struct({ ok: Schema.Boolean }),
  275. execute: () => Effect.succeed({ ok: true }),
  276. toModelOutput: () => [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
  277. })
  278. const dispatched = yield* ToolRuntime.dispatch(
  279. { remote },
  280. LLMEvent.toolCall({ id: "call_remote", name: "remote", input: {} }),
  281. )
  282. expect(dispatched.output).toEqual({
  283. structured: { ok: true },
  284. content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
  285. })
  286. expect(dispatched.result).toEqual({
  287. type: "content",
  288. value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
  289. })
  290. expect(dispatched.events.map((event) => event.type)).toEqual(["tool-result"])
  291. }),
  292. )
  293. it.effect("derives typed output schemas and preserves dynamic output schemas", () =>
  294. Effect.sync(() => {
  295. const [typed] = toDefinitions({ get_weather })
  296. const schema = { type: "object", properties: { result: { type: "string" } } } as const
  297. const [dynamic] = toDefinitions({
  298. dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }),
  299. })
  300. expect(typed?.outputSchema).toMatchObject({
  301. type: "object",
  302. properties: { condition: { type: "string" } },
  303. required: ["temperature", "condition"],
  304. additionalProperties: false,
  305. })
  306. expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
  307. expect(dynamic?.outputSchema).toEqual(schema)
  308. }),
  309. )
  310. it.effect("preserves content tool results from dynamic tools", () =>
  311. Effect.gen(function* () {
  312. const screenshot = Tool.make({
  313. description: "Capture a screenshot.",
  314. jsonSchema: { type: "object", properties: {} },
  315. execute: () =>
  316. Effect.succeed({
  317. type: "content" as const,
  318. value: [
  319. { type: "text" as const, text: "Screenshot captured." },
  320. { type: "file" as const, uri: "data:image/png;base64,AAAA", mime: "image/png" },
  321. ],
  322. }),
  323. })
  324. const events = Array.from(
  325. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { screenshot }, maxSteps: 1 }).pipe(
  326. Stream.runCollect,
  327. Effect.provide(
  328. scriptedResponses([sseEvents(toolCallChunk("call_1", "screenshot", "{}"), finishChunk("tool_calls"))]),
  329. ),
  330. ),
  331. )
  332. expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
  333. type: "tool-result",
  334. id: "call_1",
  335. name: "screenshot",
  336. result: {
  337. type: "content",
  338. value: [
  339. { type: "text", text: "Screenshot captured." },
  340. { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" },
  341. ],
  342. },
  343. })
  344. }),
  345. )
  346. it.effect("does not mistake dynamic tool output fields for dispatcher state", () =>
  347. Effect.gen(function* () {
  348. const callerOwned = { type: "json" as const, value: { ok: true }, events: ["caller-owned"] }
  349. const eventful = Tool.make({
  350. description: "Return an events field.",
  351. jsonSchema: { type: "object", properties: {} },
  352. execute: () => Effect.succeed(callerOwned),
  353. })
  354. const dispatched = yield* ToolRuntime.dispatch(
  355. { eventful },
  356. LLMEvent.toolCall({ id: "call_1", name: "eventful", input: {} }),
  357. )
  358. expect(dispatched.result).toEqual(callerOwned)
  359. expect(dispatched.events).toEqual([
  360. LLMEvent.toolResult({
  361. id: "call_1",
  362. name: "eventful",
  363. result: callerOwned,
  364. output: { structured: { ok: true }, content: [] },
  365. }),
  366. ])
  367. }),
  368. )
  369. it.effect("executes tool calls for one step without looping by default", () =>
  370. Effect.gen(function* () {
  371. const layer = scriptedResponses([
  372. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  373. sseEvents(deltaChunk({ role: "assistant", content: "Should not run." }), finishChunk("stop")),
  374. ])
  375. const events = Array.from(
  376. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather }, maxSteps: 1 }).pipe(
  377. Stream.runCollect,
  378. Effect.provide(layer),
  379. ),
  380. )
  381. expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
  382. expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" })
  383. }),
  384. )
  385. it.effect("passes tool call context to execute", () =>
  386. Effect.gen(function* () {
  387. let context: ToolExecuteContext | undefined
  388. const contextual = Tool.make({
  389. description: "Capture tool context.",
  390. parameters: Schema.Struct({ value: Schema.String }),
  391. success: Schema.Struct({ ok: Schema.Boolean }),
  392. execute: (_params, ctx) =>
  393. Effect.sync(() => {
  394. context = ctx
  395. return { ok: true }
  396. }),
  397. })
  398. const events = Array.from(
  399. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { contextual } }).pipe(
  400. Stream.runCollect,
  401. Effect.provide(
  402. scriptedResponses([
  403. sseEvents(toolCallChunk("call_ctx", "contextual", '{"value":"x"}'), finishChunk("tool_calls")),
  404. ]),
  405. ),
  406. ),
  407. )
  408. expect(events.some(LLMEvent.is.toolResult)).toBe(true)
  409. expect(context).toEqual({ id: "call_ctx", name: "contextual" })
  410. }),
  411. )
  412. it.effect("can expose tool schemas without executing tool calls", () =>
  413. Effect.gen(function* () {
  414. const layer = scriptedResponses([
  415. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  416. ])
  417. const events = Array.from(
  418. yield* LLMClient.stream(
  419. LLMRequest.update(baseRequest, { tools: toDefinitions({ get_weather: schema_only_weather }) }),
  420. ).pipe(Stream.runCollect, Effect.provide(layer)),
  421. )
  422. expect(events.find(LLMEvent.is.toolCall)).toMatchObject({ type: "tool-call", id: "call_1" })
  423. expect(events.find(LLMEvent.is.toolResult)).toBeUndefined()
  424. }),
  425. )
  426. it.effect("preserves provider metadata when folding streamed assistant content into follow-up history", () =>
  427. Effect.gen(function* () {
  428. const bodies: unknown[] = []
  429. const layer = dynamicResponse((input) =>
  430. Effect.sync(() => {
  431. bodies.push(decodeJson(input.text))
  432. return input.respond(
  433. bodies.length === 1
  434. ? sseEvents(
  435. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  436. { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } },
  437. { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "thinking" } },
  438. { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } },
  439. { type: "content_block_stop", index: 0 },
  440. {
  441. type: "content_block_start",
  442. index: 1,
  443. content_block: { type: "tool_use", id: "call_1", name: "get_weather" },
  444. },
  445. {
  446. type: "content_block_delta",
  447. index: 1,
  448. delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' },
  449. },
  450. { type: "content_block_stop", index: 1 },
  451. { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } },
  452. )
  453. : sseEvents(
  454. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  455. { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
  456. { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } },
  457. { type: "content_block_stop", index: 0 },
  458. { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
  459. ),
  460. { headers: { "content-type": "text/event-stream" } },
  461. )
  462. }),
  463. )
  464. yield* TestToolRuntime.runTools({
  465. request: LLM.updateRequest(baseRequest, {
  466. model: AnthropicMessages.route
  467. .with({ auth: Auth.header("x-api-key", "test") })
  468. .model({ id: "claude-sonnet-4-5" }),
  469. }),
  470. tools: { get_weather },
  471. }).pipe(Stream.runCollect, Effect.provide(layer))
  472. expect(bodies[1]).toMatchObject({
  473. messages: [
  474. { role: "user" },
  475. {
  476. role: "assistant",
  477. content: [
  478. { type: "thinking", thinking: "thinking", signature: "sig_1" },
  479. { type: "tool_use", id: "call_1", name: "get_weather", input: { city: "Paris" } },
  480. ],
  481. },
  482. { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1" }] },
  483. ],
  484. })
  485. }),
  486. )
  487. it.effect("replays encrypted OpenAI reasoning items with tool outputs", () =>
  488. Effect.gen(function* () {
  489. const bodies: unknown[] = []
  490. const layer = dynamicResponse((input) =>
  491. Effect.sync(() => {
  492. bodies.push(decodeJson(input.text))
  493. return input.respond(
  494. bodies.length === 1
  495. ? sseEvents(
  496. {
  497. type: "response.output_item.added",
  498. item: { type: "reasoning", id: "rs_1", encrypted_content: null },
  499. },
  500. { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
  501. { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
  502. {
  503. type: "response.output_item.done",
  504. item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
  505. },
  506. {
  507. type: "response.output_item.added",
  508. item: {
  509. type: "function_call",
  510. id: "item_1",
  511. call_id: "call_1",
  512. name: "get_weather",
  513. arguments: "",
  514. },
  515. },
  516. { type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"city":"Paris"}' },
  517. {
  518. type: "response.output_item.done",
  519. item: {
  520. type: "function_call",
  521. id: "item_1",
  522. call_id: "call_1",
  523. name: "get_weather",
  524. arguments: '{"city":"Paris"}',
  525. },
  526. },
  527. { type: "response.completed", response: {} },
  528. )
  529. : sseEvents(
  530. { type: "response.output_text.delta", item_id: "msg_1", delta: "Done." },
  531. { type: "response.completed", response: {} },
  532. ),
  533. { headers: { "content-type": "text/event-stream" } },
  534. )
  535. }),
  536. )
  537. yield* TestToolRuntime.runTools({
  538. request: LLM.request({
  539. model: OpenAIResponses.route
  540. .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
  541. .model({ id: "gpt-5.5" }),
  542. prompt: "Use the tool.",
  543. providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
  544. }),
  545. tools: { get_weather },
  546. }).pipe(Stream.runCollect, Effect.provide(layer))
  547. expect(bodies[1]).toMatchObject({
  548. include: ["reasoning.encrypted_content"],
  549. input: [
  550. { role: "user" },
  551. { type: "reasoning", summary: [], encrypted_content: "encrypted-state" },
  552. { type: "function_call", call_id: "call_1", name: "get_weather" },
  553. { type: "function_call_output", call_id: "call_1" },
  554. ],
  555. })
  556. }),
  557. )
  558. it.effect("emits tool-error for unknown tools so the model can self-correct", () =>
  559. Effect.gen(function* () {
  560. const layer = scriptedResponses([
  561. sseEvents(toolCallChunk("call_1", "missing_tool", "{}"), finishChunk("tool_calls")),
  562. sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")),
  563. ])
  564. const events = Array.from(
  565. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  566. Stream.runCollect,
  567. Effect.provide(layer),
  568. ),
  569. )
  570. const toolError = events.find(LLMEvent.is.toolError)
  571. expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" })
  572. expect(toolError?.message).toContain("Unknown tool")
  573. expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
  574. type: "tool-result",
  575. id: "call_1",
  576. name: "missing_tool",
  577. result: { type: "error", value: "Unknown tool: missing_tool" },
  578. })
  579. }),
  580. )
  581. it.effect("emits tool-error when the LLM input fails the parameters schema", () =>
  582. Effect.gen(function* () {
  583. const layer = scriptedResponses([
  584. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":42}'), finishChunk("tool_calls")),
  585. sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
  586. ])
  587. const events = Array.from(
  588. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  589. Stream.runCollect,
  590. Effect.provide(layer),
  591. ),
  592. )
  593. const toolError = events.find(LLMEvent.is.toolError)
  594. expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
  595. expect(toolError?.message).toContain("Invalid tool input")
  596. }),
  597. )
  598. it.effect("emits tool-error when the handler returns a ToolFailure", () =>
  599. Effect.gen(function* () {
  600. const layer = scriptedResponses([
  601. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"FAIL"}'), finishChunk("tool_calls")),
  602. sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")),
  603. ])
  604. const events = Array.from(
  605. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  606. Stream.runCollect,
  607. Effect.provide(layer),
  608. ),
  609. )
  610. const toolError = events.find(LLMEvent.is.toolError)
  611. expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
  612. expect(toolError?.message).toBe("Weather lookup failed for FAIL")
  613. expect(toolError?.error).toBe(weatherFailureCause)
  614. }),
  615. )
  616. it.effect("stops when the model finishes without requesting more tools", () =>
  617. Effect.gen(function* () {
  618. const layer = scriptedResponses([
  619. sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
  620. ])
  621. const events = Array.from(
  622. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  623. Stream.runCollect,
  624. Effect.provide(layer),
  625. ),
  626. )
  627. expect(events.map((event) => event.type)).toEqual([
  628. "step-start",
  629. "text-start",
  630. "text-delta",
  631. "text-end",
  632. "step-finish",
  633. "finish",
  634. ])
  635. expect(LLMResponse.text({ events })).toBe("Done.")
  636. }),
  637. )
  638. it.effect("respects maxSteps and stops the loop", () =>
  639. Effect.gen(function* () {
  640. // Every script entry asks for another tool call. With maxSteps: 2 the
  641. // runtime should run at most two model rounds and then exit even though
  642. // the model still wants to keep going.
  643. const toolCallStep = sseEvents(
  644. toolCallChunk("call_x", "get_weather", '{"city":"Paris"}'),
  645. finishChunk("tool_calls"),
  646. )
  647. const layer = scriptedResponses([toolCallStep, toolCallStep, toolCallStep])
  648. const events = Array.from(
  649. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather }, maxSteps: 2 }).pipe(
  650. Stream.runCollect,
  651. Effect.provide(layer),
  652. ),
  653. )
  654. expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
  655. expect(events.filter(LLMEvent.is.stepStart).map((event) => event.index)).toEqual([0, 1])
  656. expect(events.filter(LLMEvent.is.stepFinish).map((event) => event.index)).toEqual([0, 1])
  657. }),
  658. )
  659. it.effect("does not dispatch provider-executed tool calls", () =>
  660. Effect.gen(function* () {
  661. let streams = 0
  662. const layer = dynamicResponse((input) =>
  663. Effect.sync(() => {
  664. streams++
  665. return input.respond(
  666. sseEvents(
  667. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  668. {
  669. type: "content_block_start",
  670. index: 0,
  671. content_block: { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search" },
  672. },
  673. {
  674. type: "content_block_delta",
  675. index: 0,
  676. delta: { type: "input_json_delta", partial_json: '{"query":"x"}' },
  677. },
  678. { type: "content_block_stop", index: 0 },
  679. {
  680. type: "content_block_start",
  681. index: 1,
  682. content_block: {
  683. type: "web_search_tool_result",
  684. tool_use_id: "srvtoolu_abc",
  685. content: [{ type: "web_search_result", url: "https://example.com", title: "Example" }],
  686. },
  687. },
  688. { type: "content_block_stop", index: 1 },
  689. { type: "content_block_start", index: 2, content_block: { type: "text", text: "" } },
  690. { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } },
  691. { type: "content_block_stop", index: 2 },
  692. { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
  693. ),
  694. { headers: { "content-type": "text/event-stream" } },
  695. )
  696. }),
  697. )
  698. const events = Array.from(
  699. yield* TestToolRuntime.runTools({
  700. request: LLM.updateRequest(baseRequest, {
  701. model: AnthropicMessages.route
  702. .with({ auth: Auth.header("x-api-key", "test") })
  703. .model({ id: "claude-sonnet-4-5" }),
  704. }),
  705. tools: {},
  706. }).pipe(Stream.runCollect, Effect.provide(layer)),
  707. )
  708. expect(streams).toBe(1)
  709. expect(events.find(LLMEvent.is.toolError)).toBeUndefined()
  710. expect(events.filter(LLMEvent.is.toolCall)).toEqual([
  711. {
  712. type: "tool-call",
  713. id: "srvtoolu_abc",
  714. name: "web_search",
  715. input: { query: "x" },
  716. providerExecuted: true,
  717. },
  718. ])
  719. expect(LLMResponse.text({ events })).toBe("Done.")
  720. }),
  721. )
  722. it.effect("dispatches multiple tool calls in one step concurrently", () =>
  723. Effect.gen(function* () {
  724. const layer = scriptedResponses([
  725. sseEvents(
  726. deltaChunk({
  727. role: "assistant",
  728. tool_calls: [
  729. { index: 0, id: "c1", function: { name: "get_weather", arguments: '{"city":"Paris"}' } },
  730. { index: 1, id: "c2", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } },
  731. ],
  732. }),
  733. finishChunk("tool_calls"),
  734. ),
  735. sseEvents(deltaChunk({ role: "assistant", content: "Both done." }), finishChunk("stop")),
  736. ])
  737. const events = Array.from(
  738. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  739. Stream.runCollect,
  740. Effect.provide(layer),
  741. ),
  742. )
  743. const results = events.filter(LLMEvent.is.toolResult)
  744. expect(results).toHaveLength(2)
  745. expect(results.map((event) => event.id).toSorted()).toEqual(["c1", "c2"])
  746. }),
  747. )
  748. })