tool-output-store.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. export * as ToolOutputStore from "./tool-output-store"
  2. import path from "path"
  3. import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
  4. import { Config } from "./config"
  5. import { FSUtil } from "./fs-util"
  6. import { Global } from "./global"
  7. import { SessionSchema } from "./session/schema"
  8. import { Identifier } from "./util/identifier"
  9. import type { ToolOutput } from "@opencode-ai/llm"
  10. export const MAX_LINES = 2_000
  11. export const MAX_BYTES = 50 * 1024
  12. export const RETENTION = Duration.days(7)
  13. export const MANAGED_DIRECTORY = "tool-output"
  14. export interface BoundInput {
  15. readonly sessionID: SessionSchema.ID
  16. readonly toolCallID: string
  17. readonly output: ToolOutput
  18. }
  19. export interface BoundResult {
  20. readonly output: ToolOutput
  21. readonly outputPaths: ReadonlyArray<string>
  22. }
  23. export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolOutputStore.StorageError", {
  24. operation: Schema.Literals(["encode", "write"]),
  25. cause: Schema.Defect(),
  26. }) {}
  27. export type Error = StorageError
  28. export interface Interface {
  29. readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
  30. readonly bound: (input: BoundInput) => Effect.Effect<BoundResult, Error>
  31. readonly cleanup: () => Effect.Effect<void>
  32. }
  33. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolOutputStore") {}
  34. const takePrefix = (input: string, maximumBytes: number) => {
  35. let bytes = 0
  36. let content = ""
  37. for (const char of input) {
  38. const size = Buffer.byteLength(char, "utf-8")
  39. if (bytes + size > maximumBytes) break
  40. content += char
  41. bytes += size
  42. }
  43. return content
  44. }
  45. const takeSuffix = (input: string, maximumBytes: number) => {
  46. let bytes = 0
  47. const content: string[] = []
  48. for (const char of Array.from(input).toReversed()) {
  49. const size = Buffer.byteLength(char, "utf-8")
  50. if (bytes + size > maximumBytes) break
  51. content.unshift(char)
  52. bytes += size
  53. }
  54. return content.join("")
  55. }
  56. const preview = (text: string, maxLines: number, maxBytes: number) => {
  57. const lines = text.split("\n")
  58. const headLines = Math.ceil(maxLines / 2)
  59. const tailLines = Math.floor(maxLines / 2)
  60. const sampled =
  61. lines.length <= maxLines
  62. ? text
  63. : [
  64. lines.slice(0, headLines).join("\n"),
  65. ...(tailLines > 0 ? [lines.slice(lines.length - tailLines).join("\n")] : []),
  66. ].join("\n")
  67. if (Buffer.byteLength(sampled, "utf-8") <= maxBytes) {
  68. return lines.length <= maxLines
  69. ? { head: sampled, tail: "" }
  70. : {
  71. head: lines.slice(0, headLines).join("\n"),
  72. tail: tailLines > 0 ? lines.slice(lines.length - tailLines).join("\n") : "",
  73. }
  74. }
  75. const headBytes = Math.ceil(maxBytes / 2)
  76. const tailBytes = Math.floor(maxBytes / 2)
  77. return { head: takePrefix(sampled, headBytes), tail: takeSuffix(sampled, tailBytes) }
  78. }
  79. const boundedPreview = (text: string, marker: string, maxLines: number, maxBytes: number) => {
  80. const markerOnly = takePrefix(marker, maxBytes).split("\n").slice(0, maxLines).join("\n")
  81. const markerBytes = Buffer.byteLength(marker, "utf-8")
  82. if (maxLines <= 4 || maxBytes <= markerBytes + 4) return markerOnly
  83. const bounded = preview(text, maxLines - 4, maxBytes - markerBytes - 4)
  84. return bounded.tail ? `${bounded.head}\n\n${marker}\n\n${bounded.tail}` : `${bounded.head}\n\n${marker}`
  85. }
  86. const lineCount = (text: string) => {
  87. let count = 1
  88. for (const char of text) if (char === "\n") count++
  89. return count
  90. }
  91. export const layer = Layer.effect(
  92. Service,
  93. Effect.gen(function* () {
  94. const fs = yield* FSUtil.Service
  95. const global = yield* Global.Service
  96. const config = yield* Effect.serviceOption(Config.Service)
  97. const directory = path.join(global.data, MANAGED_DIRECTORY)
  98. const limits = Effect.fn("ToolOutputStore.limits")(function* () {
  99. if (Option.isNone(config)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
  100. const entries = yield* config.value.entries().pipe(Effect.catch(() => Effect.succeed([] as Config.Entry[])))
  101. const configured = Object.assign(
  102. {},
  103. ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info.tool_output ?? {}] : [])),
  104. )
  105. return { maxLines: configured.max_lines ?? MAX_LINES, maxBytes: configured.max_bytes ?? MAX_BYTES }
  106. })
  107. const write = Effect.fn("ToolOutputStore.write")(function* (content: string) {
  108. const file = path.join(directory, `tool_${Identifier.ascending()}`)
  109. yield* fs.ensureDir(directory).pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
  110. yield* fs
  111. .writeFileString(file, content, { flag: "wx" })
  112. .pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
  113. return file
  114. })
  115. const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
  116. const outputLimits = yield* limits()
  117. const media = input.output.content.filter((item) => item.type === "file")
  118. const text = input.output.content.filter((item) => item.type === "text")
  119. const contextual =
  120. input.output.content.length === 0
  121. ? yield* Effect.try({
  122. try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured),
  123. catch: (cause) => new StorageError({ operation: "encode", cause }),
  124. })
  125. : text.map((item) => item.text).join("")
  126. if (
  127. lineCount(contextual) <= outputLimits.maxLines &&
  128. Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
  129. )
  130. return {
  131. output: input.output,
  132. outputPaths: [],
  133. }
  134. const outputPath = yield* write(contextual)
  135. const marker = `... output truncated; full content saved to ${outputPath} ...`
  136. return {
  137. output: {
  138. structured: input.output.structured,
  139. content: [
  140. {
  141. type: "text" as const,
  142. text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
  143. },
  144. ...media,
  145. ],
  146. },
  147. outputPaths: [outputPath],
  148. }
  149. })
  150. const cleanup = Effect.fn("ToolOutputStore.cleanup")(function* () {
  151. const entries = yield* fs.readDirectory(directory).pipe(Effect.catch(() => Effect.succeed([])))
  152. const cutoff = Date.now() - Duration.toMillis(RETENTION)
  153. for (const entry of entries) {
  154. if (!entry.startsWith("tool_")) continue
  155. const file = path.join(directory, entry)
  156. const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.void))
  157. const modified = info?.mtime.pipe(
  158. Option.map((date) => date.getTime()),
  159. Option.getOrElse(() => 0),
  160. )
  161. if (modified !== undefined && modified < cutoff) yield* fs.remove(file).pipe(Effect.catch(() => Effect.void))
  162. }
  163. })
  164. return Service.of({ limits, bound, cleanup })
  165. }),
  166. )
  167. export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.defaultLayer))
  168. /** Runs retention scanning once globally rather than once per active Location. */
  169. export const cleanupLayer = Layer.effectDiscard(
  170. Effect.gen(function* () {
  171. const store = yield* Service
  172. yield* store.cleanup().pipe(Effect.repeat(Schedule.spaced(Duration.hours(1))), Effect.forkScoped)
  173. }),
  174. )
  175. export const defaultCleanupLayer = Layer.merge(defaultLayer, cleanupLayer.pipe(Layer.provide(defaultLayer)))