tool.test.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. import { expect, test } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import * as Tool from "../src/v2/effect/tool"
  4. test("tools remain valid across separate module instances", async () => {
  5. const ForeignTool = await import(`${new URL("../src/v2/effect/tool.ts", import.meta.url).href}?foreign`)
  6. const config = {
  7. description: "Foreign tool",
  8. input: Schema.Struct({ value: Schema.String }),
  9. output: Schema.Struct({ ok: Schema.Boolean }),
  10. execute: () => Effect.succeed({ output: { ok: true } }),
  11. }
  12. const tool = ForeignTool.make(config)
  13. expect(Tool.toLLMDefinition("foreign", tool)).toEqual({
  14. name: "foreign",
  15. description: "Foreign tool",
  16. inputSchema: {
  17. type: "object",
  18. properties: { value: { type: "string" } },
  19. required: ["value"],
  20. additionalProperties: false,
  21. },
  22. outputSchema: {
  23. type: "object",
  24. properties: { ok: { type: "boolean" } },
  25. required: ["ok"],
  26. additionalProperties: false,
  27. },
  28. })
  29. expect(await Effect.runPromise(Tool.decodeInput(tool.input, { value: "input" }))).toEqual({ value: "input" })
  30. })
  31. test("portable schemas validate and describe typed tools", async () => {
  32. const input: Tool.StandardSchemaType<{ count: string }, { count: number }> = {
  33. "~standard": {
  34. version: 1,
  35. vendor: "test",
  36. validate: (value) => {
  37. if (typeof value !== "object" || value === null || !("count" in value) || typeof value.count !== "string")
  38. return { issues: [{ message: "count must be numeric" }] }
  39. const count = Number(value.count)
  40. return Number.isFinite(count) ? { value: { count } } : { issues: [{ message: "count must be numeric" }] }
  41. },
  42. jsonSchema: {
  43. input: () => ({ type: "object", properties: { count: { type: "string" } } }),
  44. output: () => ({ type: "object", properties: { count: { type: "number" } } }),
  45. },
  46. },
  47. }
  48. const output: Tool.StandardSchemaType<number, string> = {
  49. "~standard": {
  50. version: 1,
  51. vendor: "test",
  52. validate: (value) => ({ value: String(value) }),
  53. jsonSchema: {
  54. input: () => ({ type: "number" }),
  55. output: () => ({ type: "string" }),
  56. },
  57. },
  58. }
  59. const tool = Tool.make({
  60. description: "Portable tool",
  61. input,
  62. output,
  63. execute: ({ count }) => Effect.succeed({ output: count + 1 }),
  64. })
  65. expect(Tool.toLLMDefinition("portable", tool)).toEqual({
  66. name: "portable",
  67. description: "Portable tool",
  68. inputSchema: { type: "object", properties: { count: { type: "string" } } },
  69. outputSchema: { type: "string" },
  70. })
  71. const decoded = await Effect.runPromise(Tool.decodeInput(tool.input, { count: "41" }))
  72. expect(decoded).toEqual({ count: 41 })
  73. expect(await Effect.runPromise(Tool.encodeOutput(tool.output, 42))).toBe("42")
  74. })
  75. test("portable schema failures become tool failures", async () => {
  76. const input: Tool.StandardSchemaType<string> = {
  77. "~standard": {
  78. version: 1,
  79. vendor: "test",
  80. validate: () => ({ issues: [{ message: "expected a string" }] }),
  81. jsonSchema: {
  82. input: () => ({ type: "string" }),
  83. output: () => ({ type: "string" }),
  84. },
  85. },
  86. }
  87. const error = await Effect.runPromiseExit(Tool.decodeInput(input, 1))
  88. expect(error.toString()).toContain("Invalid tool input: expected a string")
  89. })
  90. test("canonical results carry metadata with typed output", async () => {
  91. const input = Schema.Struct({ value: Schema.String })
  92. const output = Schema.Struct({ value: Schema.String, internal: Schema.Boolean })
  93. const tool = Tool.make({
  94. description: "Annotated tool",
  95. input,
  96. output,
  97. execute: ({ value }) => Effect.succeed({ output: { value, internal: true }, metadata: { value }, content: value }),
  98. })
  99. expect(await Effect.runPromise(tool.execute({ value: "out" }, {} as Tool.Context))).toEqual({
  100. output: { value: "out", internal: true },
  101. metadata: { value: "out" },
  102. content: "out",
  103. })
  104. })
  105. test("raw JSON schemas are render-only and omitted output means model-only", async () => {
  106. const tool = Tool.make({
  107. description: "Raw tool",
  108. input: { type: "object", properties: { value: { type: "string" } } },
  109. execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
  110. })
  111. expect(Tool.toLLMDefinition("raw", tool)).toEqual({
  112. name: "raw",
  113. description: "Raw tool",
  114. inputSchema: { type: "object", properties: { value: { type: "string" } } },
  115. })
  116. expect(await Effect.runPromise(Tool.decodeInput(tool.input, { value: 1 }))).toEqual({ value: 1 })
  117. })