read.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. export * as ReadTool from "./read"
  2. import { ToolFailure } from "@opencode-ai/llm"
  3. import path from "path"
  4. import { Effect, Layer, Schema } from "effect"
  5. import { FileSystem } from "../filesystem"
  6. import { FSUtil } from "../fs-util"
  7. import { Image } from "../image"
  8. import { Location } from "../location"
  9. import { PermissionV2 } from "../permission"
  10. import { AbsolutePath } from "../schema"
  11. import { ReadToolFileSystem } from "./read-filesystem"
  12. import { Tool } from "./tool"
  13. import { Tools } from "./tools"
  14. export const name = "read"
  15. const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
  16. const LocationInput = Schema.Struct({
  17. path: Schema.String,
  18. offset: ReadToolFileSystem.PageInput.fields.offset.annotate({
  19. description: "The 1-based directory entry or text line offset to start reading from",
  20. }),
  21. limit: ReadToolFileSystem.PageInput.fields.limit.annotate({
  22. description: "The maximum number of directory entries or text lines to read",
  23. }),
  24. })
  25. const Input = LocationInput
  26. const Output = Schema.Union([FileSystem.Content, ReadToolFileSystem.TextPage, ReadToolFileSystem.ListPage])
  27. export const layer = Layer.effectDiscard(
  28. Effect.gen(function* () {
  29. const tools = yield* Tools.Service
  30. const fs = yield* FSUtil.Service
  31. const reader = yield* ReadToolFileSystem.Service
  32. const location = yield* Location.Service
  33. const image = yield* Image.Service
  34. const permission = yield* PermissionV2.Service
  35. yield* tools
  36. .register({
  37. [name]: Tool.make({
  38. description:
  39. "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths are read directly.",
  40. input: Input,
  41. output: Output,
  42. toModelOutput: ({ input, output }) => {
  43. if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime))
  44. return []
  45. return [
  46. { type: "text", text: "Image read successfully" },
  47. { type: "file", data: output.content, mime: output.mime, name: input.path },
  48. ]
  49. },
  50. execute: (input, context) => {
  51. return Effect.gen(function* () {
  52. const absolute = path.resolve(location.directory, input.path)
  53. const selected = path.isAbsolute(input.path) ? path.dirname(absolute) : location.directory
  54. if (!path.isAbsolute(input.path) && !FSUtil.contains(location.directory, absolute))
  55. return yield* Effect.die(new Error("Path escapes the allowed read root"))
  56. const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
  57. const root = yield* fs.realPath(selected).pipe(Effect.orDie)
  58. if (!FSUtil.contains(root, real))
  59. return yield* Effect.die(new Error("Path escapes the allowed read root"))
  60. const resource = path.relative(root, real).replaceAll("\\", "/") || "."
  61. const target = AbsolutePath.make(real)
  62. const type = yield* reader.inspect(target)
  63. yield* permission.assert({
  64. action: name,
  65. resources: [resource],
  66. save: ["*"],
  67. sessionID: context.sessionID,
  68. agent: context.agent,
  69. source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
  70. })
  71. if (type === "directory") return yield* reader.list(target, { offset: input.offset, limit: input.limit })
  72. const content = yield* reader.read(target, resource, {
  73. offset: input.offset,
  74. limit: input.limit,
  75. })
  76. if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
  77. return yield* image
  78. .normalize(resource, { ...content, encoding: "base64" })
  79. .pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
  80. }
  81. if ("encoding" in content && content.encoding === "base64")
  82. return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError(resource))
  83. return content
  84. }).pipe(
  85. Effect.mapError((error) => {
  86. const message =
  87. error instanceof ReadToolFileSystem.BinaryFileError ||
  88. error instanceof ReadToolFileSystem.MediaIngestLimitError ||
  89. error instanceof Image.DecodeError ||
  90. error instanceof Image.SizeError
  91. ? error.message
  92. : `Unable to read ${input.path}`
  93. return new ToolFailure({ message })
  94. }),
  95. )
  96. },
  97. }),
  98. })
  99. .pipe(Effect.orDie)
  100. }),
  101. )