tool-runtime.test.ts 32 KB

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