wip
parent
ece719dc77
commit
c47b976e67
|
|
@ -4,7 +4,7 @@ import { Provider } from "../provider/provider"
|
|||
import { generateObject, type ModelMessage } from "ai"
|
||||
import { SystemPrompt } from "../session/system"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Truncate } from "../session/truncation"
|
||||
import { Truncate } from "../tool/truncation"
|
||||
|
||||
import PROMPT_GENERATE from "./generate.txt"
|
||||
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Identifier } from "../id/id"
|
|||
|
||||
const DEFAULT_READ_LIMIT = 2000
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
const MAX_BYTES = 50 * 1024
|
||||
|
||||
export const ReadTool = Tool.define("read", {
|
||||
description: DESCRIPTION,
|
||||
|
|
@ -77,6 +78,7 @@ export const ReadTool = Tool.define("read", {
|
|||
output: msg,
|
||||
metadata: {
|
||||
preview: msg,
|
||||
truncated: false,
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
|
|
@ -97,9 +99,21 @@ export const ReadTool = Tool.define("read", {
|
|||
const limit = params.limit ?? DEFAULT_READ_LIMIT
|
||||
const offset = params.offset || 0
|
||||
const lines = await file.text().then((text) => text.split("\n"))
|
||||
const raw = lines.slice(offset, offset + limit).map((line) => {
|
||||
return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line
|
||||
})
|
||||
|
||||
const raw: string[] = []
|
||||
let bytes = 0
|
||||
let truncatedByBytes = false
|
||||
for (let i = offset; i < Math.min(lines.length, offset + limit); i++) {
|
||||
const line = lines[i].length > MAX_LINE_LENGTH ? lines[i].substring(0, MAX_LINE_LENGTH) + "..." : lines[i]
|
||||
const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
|
||||
if (bytes + size > MAX_BYTES) {
|
||||
truncatedByBytes = true
|
||||
break
|
||||
}
|
||||
raw.push(line)
|
||||
bytes += size
|
||||
}
|
||||
|
||||
const content = raw.map((line, index) => {
|
||||
return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
|
||||
})
|
||||
|
|
@ -109,10 +123,13 @@ export const ReadTool = Tool.define("read", {
|
|||
output += content.join("\n")
|
||||
|
||||
const totalLines = lines.length
|
||||
const lastReadLine = offset + content.length
|
||||
const lastReadLine = offset + raw.length
|
||||
const hasMoreLines = totalLines > lastReadLine
|
||||
const truncated = hasMoreLines || truncatedByBytes
|
||||
|
||||
if (hasMoreLines) {
|
||||
if (truncatedByBytes) {
|
||||
output += `\n\n(Output truncated at ${MAX_BYTES} bytes. Use 'offset' parameter to read beyond line ${lastReadLine})`
|
||||
} else if (hasMoreLines) {
|
||||
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${lastReadLine})`
|
||||
} else {
|
||||
output += `\n\n(End of file - total ${totalLines} lines)`
|
||||
|
|
@ -128,6 +145,7 @@ export const ReadTool = Tool.define("read", {
|
|||
output,
|
||||
metadata: {
|
||||
preview,
|
||||
truncated,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { CodeSearchTool } from "./codesearch"
|
|||
import { Flag } from "@/flag/flag"
|
||||
import { Log } from "@/util/log"
|
||||
import { LspTool } from "./lsp"
|
||||
import { Truncate } from "../session/truncation"
|
||||
import { Truncate } from "./truncation"
|
||||
|
||||
export namespace ToolRegistry {
|
||||
const log = Log.create({ service: "tool.registry" })
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import z from "zod"
|
|||
import type { MessageV2 } from "../session/message-v2"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import type { PermissionNext } from "../permission/next"
|
||||
import { Truncate } from "../session/truncation"
|
||||
import { Truncate } from "./truncation"
|
||||
|
||||
export namespace Tool {
|
||||
interface Metadata {
|
||||
|
|
@ -66,6 +66,10 @@ export namespace Tool {
|
|||
)
|
||||
}
|
||||
const result = await execute(args, ctx)
|
||||
// skip truncation for tools that handle it themselves
|
||||
if (result.metadata.truncated !== undefined) {
|
||||
return result
|
||||
}
|
||||
const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
|
||||
return {
|
||||
...result,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { tmpdir } from "../fixture/fixture"
|
|||
import { PermissionNext } from "../../src/permission/next"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
|
||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||
|
||||
const ctx = {
|
||||
sessionID: "test",
|
||||
messageID: "",
|
||||
|
|
@ -165,3 +167,123 @@ describe("tool.read env file blocking", () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool.read truncation", () => {
|
||||
test("truncates large file by bytes and sets truncated metadata", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
|
||||
await Bun.write(path.join(dir, "large.json"), content)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "large.json") }, ctx)
|
||||
expect(result.metadata.truncated).toBe(true)
|
||||
expect(result.output).toContain("Output truncated at")
|
||||
expect(result.output).toContain("bytes")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("truncates by line count when limit is specified", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||
await Bun.write(path.join(dir, "many-lines.txt"), lines)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx)
|
||||
expect(result.metadata.truncated).toBe(true)
|
||||
expect(result.output).toContain("File has more lines")
|
||||
expect(result.output).toContain("line0")
|
||||
expect(result.output).toContain("line9")
|
||||
expect(result.output).not.toContain("line10")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("does not truncate small file", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "small.txt"), "hello world")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "small.txt") }, ctx)
|
||||
expect(result.metadata.truncated).toBe(false)
|
||||
expect(result.output).toContain("End of file")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("respects offset parameter", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `line${i}`).join("\n")
|
||||
await Bun.write(path.join(dir, "offset.txt"), lines)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx)
|
||||
expect(result.output).toContain("line10")
|
||||
expect(result.output).toContain("line14")
|
||||
expect(result.output).not.toContain("line0")
|
||||
expect(result.output).not.toContain("line15")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("truncates long lines", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const longLine = "x".repeat(3000)
|
||||
await Bun.write(path.join(dir, "long-line.txt"), longLine)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "long-line.txt") }, ctx)
|
||||
expect(result.output).toContain("...")
|
||||
expect(result.output.length).toBeLessThan(3000)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("image files set truncated to false", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
// 1x1 red PNG
|
||||
const png = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
)
|
||||
await Bun.write(path.join(dir, "image.png"), png)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "image.png") }, ctx)
|
||||
expect(result.metadata.truncated).toBe(false)
|
||||
expect(result.attachments).toBeDefined()
|
||||
expect(result.attachments?.length).toBe(1)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, test, expect } from "bun:test"
|
||||
import { Truncate } from "../../src/session/truncation"
|
||||
import { Truncate } from "../../src/tool/truncation"
|
||||
import path from "path"
|
||||
|
||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||
Loading…
Reference in New Issue