tool-runtime.test.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802
  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("models canonical tool files with explicit data, url, and file sources", () =>
  192. Effect.sync(() => {
  193. const decode = Schema.decodeUnknownSync(ToolContent)
  194. expect(decode({ type: "file", source: { type: "data", data: "AAAA" }, mime: "image/png" })).toEqual({
  195. type: "file",
  196. source: { type: "data", data: "AAAA" },
  197. mime: "image/png",
  198. })
  199. expect(
  200. decode({ type: "file", source: { type: "url", url: "https://example.test/image.png" }, mime: "image/png" }),
  201. ).toEqual({
  202. type: "file",
  203. source: { type: "url", url: "https://example.test/image.png" },
  204. mime: "image/png",
  205. })
  206. expect(
  207. decode({ type: "file", source: { type: "file", uri: "file:///tmp/image.png" }, mime: "image/png" }),
  208. ).toEqual({
  209. type: "file",
  210. source: { type: "file", uri: "file:///tmp/image.png" },
  211. mime: "image/png",
  212. })
  213. }),
  214. )
  215. it.effect("converts canonical data files deliberately and rejects unmaterialized sources", () =>
  216. Effect.sync(() => {
  217. expect(
  218. ToolOutput.toResultValue(
  219. ToolOutput.make({}, [{ type: "file", source: { type: "data", data: "AAAA" }, mime: "image/png" }]),
  220. ),
  221. ).toEqual({ type: "content", value: [{ type: "media", mediaType: "image/png", data: "AAAA" }] })
  222. expect(
  223. ToolOutput.toResultValue(
  224. ToolOutput.make({}, [
  225. { type: "file", source: { type: "url", url: "https://example.test/image.png" }, mime: "image/png" },
  226. ]),
  227. ),
  228. ).toEqual({
  229. type: "error",
  230. value: 'Tool file source "url" must be materialized to inline data before provider conversion',
  231. })
  232. expect(
  233. ToolOutput.toResultValue(
  234. ToolOutput.make({}, [
  235. { type: "file", source: { type: "file", uri: "file:///tmp/image.png" }, mime: "image/png" },
  236. ]),
  237. ),
  238. ).toEqual({
  239. type: "error",
  240. value: 'Tool file source "file" must be materialized to inline data before provider conversion',
  241. })
  242. expect(toolFileSourceFromUri("data:image/png;base64,AAAA")).toEqual({ type: "data", data: "AAAA" })
  243. expect(toolFileSourceFromUri("https://example.test/image.png")).toEqual({
  244. type: "url",
  245. url: "https://example.test/image.png",
  246. })
  247. expect(toolFileSourceFromUri("file:///tmp/image.png")).toEqual({ type: "file", uri: "file:///tmp/image.png" })
  248. expect(() => toolFileSourceFromUri("opaque-value")).toThrow("Unsupported tool file URI")
  249. expect(() =>
  250. ToolOutput.fromResultValue({
  251. type: "content",
  252. value: [{ type: "media", mediaType: "image/png", data: "https://example.test/image.png" }],
  253. }),
  254. ).toThrow("Legacy tool-result media must contain raw base64 bytes or a base64 data URI")
  255. }),
  256. )
  257. it.effect("settles projected url files as materialization errors", () =>
  258. Effect.gen(function* () {
  259. const remote = Tool.make({
  260. description: "Return a remote file.",
  261. parameters: Schema.Struct({}),
  262. success: Schema.Struct({ ok: Schema.Boolean }),
  263. execute: () => Effect.succeed({ ok: true }),
  264. toModelOutput: () => [
  265. { type: "file", source: { type: "url", url: "https://example.test/image.png" }, mime: "image/png" },
  266. ],
  267. })
  268. const dispatched = yield* ToolRuntime.dispatch(
  269. { remote },
  270. LLMEvent.toolCall({ id: "call_remote", name: "remote", input: {} }),
  271. )
  272. expect(dispatched.output).toBeUndefined()
  273. expect(dispatched.result).toEqual({
  274. type: "error",
  275. value: 'Tool file source "url" must be materialized to inline data before provider conversion',
  276. })
  277. expect(dispatched.events.map((event) => event.type)).toEqual(["tool-error", "tool-result"])
  278. }),
  279. )
  280. it.effect("derives typed output schemas and preserves dynamic output schemas", () =>
  281. Effect.sync(() => {
  282. const [typed] = toDefinitions({ get_weather })
  283. const schema = { type: "object", properties: { result: { type: "string" } } } as const
  284. const [dynamic] = toDefinitions({
  285. dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }),
  286. })
  287. expect(typed?.outputSchema).toMatchObject({
  288. type: "object",
  289. properties: { condition: { type: "string" } },
  290. required: ["temperature", "condition"],
  291. additionalProperties: false,
  292. })
  293. expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
  294. expect(dynamic?.outputSchema).toEqual(schema)
  295. }),
  296. )
  297. it.effect("preserves content tool results from dynamic tools", () =>
  298. Effect.gen(function* () {
  299. const screenshot = Tool.make({
  300. description: "Capture a screenshot.",
  301. jsonSchema: { type: "object", properties: {} },
  302. execute: () =>
  303. Effect.succeed({
  304. type: "content" as const,
  305. value: [
  306. { type: "text" as const, text: "Screenshot captured." },
  307. { type: "media" as const, mediaType: "image/png", data: "AAAA" },
  308. ],
  309. }),
  310. })
  311. const events = Array.from(
  312. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { screenshot }, maxSteps: 1 }).pipe(
  313. Stream.runCollect,
  314. Effect.provide(
  315. scriptedResponses([sseEvents(toolCallChunk("call_1", "screenshot", "{}"), finishChunk("tool_calls"))]),
  316. ),
  317. ),
  318. )
  319. expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
  320. type: "tool-result",
  321. id: "call_1",
  322. name: "screenshot",
  323. result: {
  324. type: "content",
  325. value: [
  326. { type: "text", text: "Screenshot captured." },
  327. { type: "media", mediaType: "image/png", data: "AAAA" },
  328. ],
  329. },
  330. })
  331. }),
  332. )
  333. it.effect("does not mistake dynamic tool output fields for dispatcher state", () =>
  334. Effect.gen(function* () {
  335. const callerOwned = { type: "json" as const, value: { ok: true }, events: ["caller-owned"] }
  336. const eventful = Tool.make({
  337. description: "Return an events field.",
  338. jsonSchema: { type: "object", properties: {} },
  339. execute: () => Effect.succeed(callerOwned),
  340. })
  341. const dispatched = yield* ToolRuntime.dispatch(
  342. { eventful },
  343. LLMEvent.toolCall({ id: "call_1", name: "eventful", input: {} }),
  344. )
  345. expect(dispatched.result).toEqual(callerOwned)
  346. expect(dispatched.events).toEqual([
  347. LLMEvent.toolResult({
  348. id: "call_1",
  349. name: "eventful",
  350. result: callerOwned,
  351. output: { structured: { ok: true }, content: [] },
  352. }),
  353. ])
  354. }),
  355. )
  356. it.effect("executes tool calls for one step without looping by default", () =>
  357. Effect.gen(function* () {
  358. const layer = scriptedResponses([
  359. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  360. sseEvents(deltaChunk({ role: "assistant", content: "Should not run." }), finishChunk("stop")),
  361. ])
  362. const events = Array.from(
  363. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather }, maxSteps: 1 }).pipe(
  364. Stream.runCollect,
  365. Effect.provide(layer),
  366. ),
  367. )
  368. expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
  369. expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" })
  370. }),
  371. )
  372. it.effect("passes tool call context to execute", () =>
  373. Effect.gen(function* () {
  374. let context: ToolExecuteContext | undefined
  375. const contextual = Tool.make({
  376. description: "Capture tool context.",
  377. parameters: Schema.Struct({ value: Schema.String }),
  378. success: Schema.Struct({ ok: Schema.Boolean }),
  379. execute: (_params, ctx) =>
  380. Effect.sync(() => {
  381. context = ctx
  382. return { ok: true }
  383. }),
  384. })
  385. const events = Array.from(
  386. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { contextual } }).pipe(
  387. Stream.runCollect,
  388. Effect.provide(
  389. scriptedResponses([
  390. sseEvents(toolCallChunk("call_ctx", "contextual", '{"value":"x"}'), finishChunk("tool_calls")),
  391. ]),
  392. ),
  393. ),
  394. )
  395. expect(events.some(LLMEvent.is.toolResult)).toBe(true)
  396. expect(context).toEqual({ id: "call_ctx", name: "contextual" })
  397. }),
  398. )
  399. it.effect("can expose tool schemas without executing tool calls", () =>
  400. Effect.gen(function* () {
  401. const layer = scriptedResponses([
  402. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  403. ])
  404. const events = Array.from(
  405. yield* LLMClient.stream(
  406. LLMRequest.update(baseRequest, { tools: toDefinitions({ get_weather: schema_only_weather }) }),
  407. ).pipe(Stream.runCollect, Effect.provide(layer)),
  408. )
  409. expect(events.find(LLMEvent.is.toolCall)).toMatchObject({ type: "tool-call", id: "call_1" })
  410. expect(events.find(LLMEvent.is.toolResult)).toBeUndefined()
  411. }),
  412. )
  413. it.effect("preserves provider metadata when folding streamed assistant content into follow-up history", () =>
  414. Effect.gen(function* () {
  415. const bodies: unknown[] = []
  416. const layer = dynamicResponse((input) =>
  417. Effect.sync(() => {
  418. bodies.push(decodeJson(input.text))
  419. return input.respond(
  420. bodies.length === 1
  421. ? sseEvents(
  422. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  423. { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } },
  424. { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "thinking" } },
  425. { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } },
  426. { type: "content_block_stop", index: 0 },
  427. {
  428. type: "content_block_start",
  429. index: 1,
  430. content_block: { type: "tool_use", id: "call_1", name: "get_weather" },
  431. },
  432. {
  433. type: "content_block_delta",
  434. index: 1,
  435. delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' },
  436. },
  437. { type: "content_block_stop", index: 1 },
  438. { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } },
  439. )
  440. : sseEvents(
  441. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  442. { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
  443. { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } },
  444. { type: "content_block_stop", index: 0 },
  445. { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
  446. ),
  447. { headers: { "content-type": "text/event-stream" } },
  448. )
  449. }),
  450. )
  451. yield* TestToolRuntime.runTools({
  452. request: LLM.updateRequest(baseRequest, {
  453. model: AnthropicMessages.route
  454. .with({ auth: Auth.header("x-api-key", "test") })
  455. .model({ id: "claude-sonnet-4-5" }),
  456. }),
  457. tools: { get_weather },
  458. }).pipe(Stream.runCollect, Effect.provide(layer))
  459. expect(bodies[1]).toMatchObject({
  460. messages: [
  461. { role: "user" },
  462. {
  463. role: "assistant",
  464. content: [
  465. { type: "thinking", thinking: "thinking", signature: "sig_1" },
  466. { type: "tool_use", id: "call_1", name: "get_weather", input: { city: "Paris" } },
  467. ],
  468. },
  469. { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1" }] },
  470. ],
  471. })
  472. }),
  473. )
  474. it.effect("replays encrypted OpenAI reasoning items with tool outputs", () =>
  475. Effect.gen(function* () {
  476. const bodies: unknown[] = []
  477. const layer = dynamicResponse((input) =>
  478. Effect.sync(() => {
  479. bodies.push(decodeJson(input.text))
  480. return input.respond(
  481. bodies.length === 1
  482. ? sseEvents(
  483. {
  484. type: "response.output_item.added",
  485. item: { type: "reasoning", id: "rs_1", encrypted_content: null },
  486. },
  487. { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
  488. { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
  489. {
  490. type: "response.output_item.done",
  491. item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
  492. },
  493. {
  494. type: "response.output_item.added",
  495. item: {
  496. type: "function_call",
  497. id: "item_1",
  498. call_id: "call_1",
  499. name: "get_weather",
  500. arguments: "",
  501. },
  502. },
  503. { type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"city":"Paris"}' },
  504. {
  505. type: "response.output_item.done",
  506. item: {
  507. type: "function_call",
  508. id: "item_1",
  509. call_id: "call_1",
  510. name: "get_weather",
  511. arguments: '{"city":"Paris"}',
  512. },
  513. },
  514. { type: "response.completed", response: {} },
  515. )
  516. : sseEvents(
  517. { type: "response.output_text.delta", item_id: "msg_1", delta: "Done." },
  518. { type: "response.completed", response: {} },
  519. ),
  520. { headers: { "content-type": "text/event-stream" } },
  521. )
  522. }),
  523. )
  524. yield* TestToolRuntime.runTools({
  525. request: LLM.request({
  526. model: OpenAIResponses.route
  527. .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
  528. .model({ id: "gpt-5.5" }),
  529. prompt: "Use the tool.",
  530. providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
  531. }),
  532. tools: { get_weather },
  533. }).pipe(Stream.runCollect, Effect.provide(layer))
  534. expect(bodies[1]).toMatchObject({
  535. include: ["reasoning.encrypted_content"],
  536. input: [
  537. { role: "user" },
  538. { type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" },
  539. { type: "function_call", call_id: "call_1", name: "get_weather" },
  540. { type: "function_call_output", call_id: "call_1" },
  541. ],
  542. })
  543. }),
  544. )
  545. it.effect("emits tool-error for unknown tools so the model can self-correct", () =>
  546. Effect.gen(function* () {
  547. const layer = scriptedResponses([
  548. sseEvents(toolCallChunk("call_1", "missing_tool", "{}"), finishChunk("tool_calls")),
  549. sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")),
  550. ])
  551. const events = Array.from(
  552. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  553. Stream.runCollect,
  554. Effect.provide(layer),
  555. ),
  556. )
  557. const toolError = events.find(LLMEvent.is.toolError)
  558. expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" })
  559. expect(toolError?.message).toContain("Unknown tool")
  560. expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
  561. type: "tool-result",
  562. id: "call_1",
  563. name: "missing_tool",
  564. result: { type: "error", value: "Unknown tool: missing_tool" },
  565. })
  566. }),
  567. )
  568. it.effect("emits tool-error when the LLM input fails the parameters schema", () =>
  569. Effect.gen(function* () {
  570. const layer = scriptedResponses([
  571. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":42}'), finishChunk("tool_calls")),
  572. sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
  573. ])
  574. const events = Array.from(
  575. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  576. Stream.runCollect,
  577. Effect.provide(layer),
  578. ),
  579. )
  580. const toolError = events.find(LLMEvent.is.toolError)
  581. expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
  582. expect(toolError?.message).toContain("Invalid tool input")
  583. }),
  584. )
  585. it.effect("emits tool-error when the handler returns a ToolFailure", () =>
  586. Effect.gen(function* () {
  587. const layer = scriptedResponses([
  588. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"FAIL"}'), finishChunk("tool_calls")),
  589. sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")),
  590. ])
  591. const events = Array.from(
  592. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  593. Stream.runCollect,
  594. Effect.provide(layer),
  595. ),
  596. )
  597. const toolError = events.find(LLMEvent.is.toolError)
  598. expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
  599. expect(toolError?.message).toBe("Weather lookup failed for FAIL")
  600. expect(toolError?.error).toBe(weatherFailureCause)
  601. }),
  602. )
  603. it.effect("stops when the model finishes without requesting more tools", () =>
  604. Effect.gen(function* () {
  605. const layer = scriptedResponses([
  606. sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
  607. ])
  608. const events = Array.from(
  609. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  610. Stream.runCollect,
  611. Effect.provide(layer),
  612. ),
  613. )
  614. expect(events.map((event) => event.type)).toEqual([
  615. "step-start",
  616. "text-start",
  617. "text-delta",
  618. "text-end",
  619. "step-finish",
  620. "finish",
  621. ])
  622. expect(LLMResponse.text({ events })).toBe("Done.")
  623. }),
  624. )
  625. it.effect("respects maxSteps and stops the loop", () =>
  626. Effect.gen(function* () {
  627. // Every script entry asks for another tool call. With maxSteps: 2 the
  628. // runtime should run at most two model rounds and then exit even though
  629. // the model still wants to keep going.
  630. const toolCallStep = sseEvents(
  631. toolCallChunk("call_x", "get_weather", '{"city":"Paris"}'),
  632. finishChunk("tool_calls"),
  633. )
  634. const layer = scriptedResponses([toolCallStep, toolCallStep, toolCallStep])
  635. const events = Array.from(
  636. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather }, maxSteps: 2 }).pipe(
  637. Stream.runCollect,
  638. Effect.provide(layer),
  639. ),
  640. )
  641. expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
  642. expect(events.filter(LLMEvent.is.stepStart).map((event) => event.index)).toEqual([0, 1])
  643. expect(events.filter(LLMEvent.is.stepFinish).map((event) => event.index)).toEqual([0, 1])
  644. }),
  645. )
  646. it.effect("does not dispatch provider-executed tool calls", () =>
  647. Effect.gen(function* () {
  648. let streams = 0
  649. const layer = dynamicResponse((input) =>
  650. Effect.sync(() => {
  651. streams++
  652. return input.respond(
  653. sseEvents(
  654. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  655. {
  656. type: "content_block_start",
  657. index: 0,
  658. content_block: { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search" },
  659. },
  660. {
  661. type: "content_block_delta",
  662. index: 0,
  663. delta: { type: "input_json_delta", partial_json: '{"query":"x"}' },
  664. },
  665. { type: "content_block_stop", index: 0 },
  666. {
  667. type: "content_block_start",
  668. index: 1,
  669. content_block: {
  670. type: "web_search_tool_result",
  671. tool_use_id: "srvtoolu_abc",
  672. content: [{ type: "web_search_result", url: "https://example.com", title: "Example" }],
  673. },
  674. },
  675. { type: "content_block_stop", index: 1 },
  676. { type: "content_block_start", index: 2, content_block: { type: "text", text: "" } },
  677. { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } },
  678. { type: "content_block_stop", index: 2 },
  679. { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
  680. ),
  681. { headers: { "content-type": "text/event-stream" } },
  682. )
  683. }),
  684. )
  685. const events = Array.from(
  686. yield* TestToolRuntime.runTools({
  687. request: LLM.updateRequest(baseRequest, {
  688. model: AnthropicMessages.route
  689. .with({ auth: Auth.header("x-api-key", "test") })
  690. .model({ id: "claude-sonnet-4-5" }),
  691. }),
  692. tools: {},
  693. }).pipe(Stream.runCollect, Effect.provide(layer)),
  694. )
  695. expect(streams).toBe(1)
  696. expect(events.find(LLMEvent.is.toolError)).toBeUndefined()
  697. expect(events.filter(LLMEvent.is.toolCall)).toEqual([
  698. {
  699. type: "tool-call",
  700. id: "srvtoolu_abc",
  701. name: "web_search",
  702. input: { query: "x" },
  703. providerExecuted: true,
  704. },
  705. ])
  706. expect(LLMResponse.text({ events })).toBe("Done.")
  707. }),
  708. )
  709. it.effect("dispatches multiple tool calls in one step concurrently", () =>
  710. Effect.gen(function* () {
  711. const layer = scriptedResponses([
  712. sseEvents(
  713. deltaChunk({
  714. role: "assistant",
  715. tool_calls: [
  716. { index: 0, id: "c1", function: { name: "get_weather", arguments: '{"city":"Paris"}' } },
  717. { index: 1, id: "c2", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } },
  718. ],
  719. }),
  720. finishChunk("tool_calls"),
  721. ),
  722. sseEvents(deltaChunk({ role: "assistant", content: "Both done." }), finishChunk("stop")),
  723. ])
  724. const events = Array.from(
  725. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  726. Stream.runCollect,
  727. Effect.provide(layer),
  728. ),
  729. )
  730. const results = events.filter(LLMEvent.is.toolResult)
  731. expect(results).toHaveLength(2)
  732. expect(results.map((event) => event.id).toSorted()).toEqual(["c1", "c2"])
  733. }),
  734. )
  735. })