Skip to content

Commit f1fcf3f

Browse files
committed
fix(core): keep the tail when tool output is truncated
1 parent da8c021 commit f1fcf3f

2 files changed

Lines changed: 222 additions & 39 deletions

File tree

‎packages/core/src/tool-output.ts‎

Lines changed: 80 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -77,41 +77,95 @@ const layer = Layer.effect(
7777
if (lines.length <= limits.maxLines && totalBytes <= limits.maxBytes)
7878
return { ...result, metadata: { ...result.metadata, truncated: false } }
7979

80-
const kept: string[] = []
81-
let bytes = 0
82-
for (const line of lines.slice(0, limits.maxLines)) {
83-
const size = Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0)
84-
if (bytes + size > limits.maxBytes) break
85-
kept.push(line)
86-
bytes += size
87-
}
8880
const file = path.join(directory, fileName())
8981
yield* fs.ensureDir(directory).pipe(Effect.orDie)
9082
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
91-
const shown = kept.length > 0 ? `lines 1-${kept.length}` : "0 lines"
92-
const marker = `[showing ${shown} of ${lines.length}; full output saved to ${file}]`
83+
84+
// Keep both ends: the head carries setup/context, the tail carries the result (often an error) that matters most.
85+
// When the marker alone would not fit, keep the old behavior and give the whole byte budget to content.
86+
const markerBytes = Buffer.byteLength(
87+
`[showing lines ${lines.length}-${lines.length} and ${lines.length}-${lines.length} of ${lines.length}; full output saved to ${file}]`,
88+
"utf-8",
89+
)
90+
const budget = markerBytes + 2 <= limits.maxBytes ? limits.maxBytes - markerBytes - 2 : limits.maxBytes
91+
const headBudget = Math.floor(budget / 2)
92+
const headLineBudget = Math.ceil(limits.maxLines / 2)
93+
94+
const head: string[] = []
95+
let headBytes = 0
96+
for (const line of lines.slice(0, headLineBudget)) {
97+
const size = Buffer.byteLength(line, "utf-8") + (head.length > 0 ? 1 : 0)
98+
if (headBytes + size > headBudget) break
99+
head.push(line)
100+
headBytes += size
101+
}
102+
103+
const tail: string[] = []
104+
let tailBytes = 0
105+
const tailLineBudget = limits.maxLines - head.length
106+
for (let i = lines.length - 1; i >= head.length && tail.length < tailLineBudget; i--) {
107+
const line = lines[i]
108+
const size = Buffer.byteLength(line, "utf-8") + (tail.length > 0 ? 1 : 0)
109+
if (tailBytes + size > budget - headBytes) break
110+
tail.unshift(line)
111+
tailBytes += size
112+
}
113+
114+
const tailStart = lines.length - tail.length
115+
116+
// The tail often ends early (a single oversized line), so give the head whatever budget is left.
117+
for (let i = head.length; i < tailStart && head.length + tail.length < limits.maxLines; i++) {
118+
const line = lines[i]
119+
const size = Buffer.byteLength(line, "utf-8") + (head.length > 0 ? 1 : 0)
120+
if (headBytes + tailBytes + size > budget) break
121+
head.push(line)
122+
headBytes += size
123+
}
124+
125+
const shown = [
126+
...(head.length > 0 ? [`1-${head.length}`] : []),
127+
...(tail.length > 0 ? [`${tailStart + 1}-${lines.length}`] : []),
128+
].join(" and ")
129+
const marker = `[showing ${shown ? `lines ${shown}` : "0 lines"} of ${lines.length}; full output saved to ${file}]`
130+
131+
// Each kept line goes back into the text item it came from, so attachments stay between the same lines.
132+
const ranges: { index: number; start: number; end: number }[] = []
133+
let lineIndex = 0
134+
content.forEach((item, index) => {
135+
if (item.type !== "text") return
136+
const start = lineIndex
137+
lineIndex += item.text.split("\n").length
138+
ranges.push({ index, start, end: lineIndex })
139+
})
140+
if (text.endsWith("\n") && ranges.length > 0) ranges[ranges.length - 1].end -= 1
141+
142+
const markerIndex =
143+
head.length > 0
144+
? ranges.find((range) => head.length - 1 >= range.start && head.length - 1 < range.end)?.index
145+
: ranges[0]?.index
93146
const bounded: Tool.Content[] = []
94-
let remaining = kept.join("\n").length
95-
let seenText = false
96147
let marked = false
97-
for (const item of content) {
148+
content.forEach((item, index) => {
98149
if (item.type === "file") {
99150
bounded.push(item)
100-
continue
151+
return
101152
}
102-
if (seenText && remaining > 0) remaining--
103-
seenText = true
104-
if (remaining >= item.text.length) {
105-
bounded.push(item)
106-
remaining -= item.text.length
107-
continue
153+
const range = ranges.find((candidate) => candidate.index === index)
154+
if (!range) return
155+
const parts: string[] = []
156+
const headLines = lines.slice(range.start, Math.min(range.end, head.length))
157+
if (headLines.length > 0) parts.push(headLines.join("\n"))
158+
if (!marked && (markerIndex === undefined || markerIndex === index)) {
159+
parts.push(marker)
160+
marked = true
108161
}
109-
if (remaining > 0) bounded.push({ ...item, text: item.text.slice(0, remaining) })
110-
if (!marked) bounded.push({ type: "text", text: marker })
111-
remaining = 0
112-
marked = true
113-
}
114-
if (!marked) bounded.push({ type: "text", text: marker })
162+
if (tail.length > 0) {
163+
const tailLines = lines.slice(Math.max(range.start, tailStart), range.end)
164+
if (tailLines.length > 0) parts.push(tailLines.join("\n"))
165+
}
166+
const itemText = parts.filter((part) => part.length > 0).join("\n")
167+
if (itemText) bounded.push({ ...item, text: itemText })
168+
})
115169
return {
116170
...result,
117171
content: bounded,

‎packages/core/test/tool-output.test.ts‎

Lines changed: 142 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const withStore = <A, E, R>(
2727
})
2828

2929
describe("ToolOutput", () => {
30-
it.live("writes oversized text and returns a bounded preview", () =>
30+
it.live("writes oversized text and returns a bounded head+tail preview", () =>
3131
withStore(
3232
(service, fs) =>
3333
Effect.gen(function* () {
@@ -40,50 +40,180 @@ describe("ToolOutput", () => {
4040
if (typeof outputPath !== "string") return
4141
expect(yield* fs.readFileString(outputPath)).toBe("one\ntwo\nthree")
4242
expect(result.content).toEqual([
43-
{ type: "text", text: "one\ntwo" },
44-
{ type: "text", text: `[showing lines 1-2 of 3; full output saved to ${outputPath}]` },
43+
{
44+
type: "text",
45+
text: `one\n[showing lines 1-1 and 3-3 of 3; full output saved to ${outputPath}]\nthree`,
46+
},
4547
])
4648
}),
4749
{ maxLines: 2, maxBytes: 1_000 },
4850
),
4951
)
5052

51-
it.live("reports lines shown under the byte limit", () =>
53+
it.live("keeps the tail when only the tail fits the byte limit", () =>
5254
withStore(
5355
(output) =>
5456
Effect.gen(function* () {
5557
const result = yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] })
5658
expect(result.content).toEqual([
57-
{ type: "text", text: "one" },
59+
{ type: "text", text: expect.stringMatching(/^\[showing lines 2-2 of 2; full output saved to .+\]\ntwo$/) },
60+
])
61+
}),
62+
{ maxLines: 100, maxBytes: 5 },
63+
),
64+
)
65+
66+
it.live("keeps the head and tail within the line limit", () =>
67+
withStore(
68+
(output) =>
69+
Effect.gen(function* () {
70+
const lines = Array.from({ length: 100 }, (_, index) => `l${index + 1}`)
71+
const result = yield* output.truncate({ content: [{ type: "text", text: lines.join("\n") }] })
72+
const outputPath = result.metadata?.outputPath
73+
expect(result.content).toEqual([
5874
{
5975
type: "text",
60-
text: expect.stringMatching(/^\[showing lines 1-1 of 2; full output saved to .+\]$/),
76+
text: `${lines.slice(0, 5).join("\n")}\n[showing lines 1-5 and 96-100 of 100; full output saved to ${outputPath}]\n${lines.slice(-5).join("\n")}`,
6177
},
6278
])
79+
const text = result.content[0]?.type === "text" ? result.content[0].text : ""
80+
expect(text.split("\n").filter((line) => !line.startsWith("[showing "))).toHaveLength(10)
6381
}),
64-
{ maxLines: 100, maxBytes: 5 },
82+
{ maxLines: 10, maxBytes: 1_000_000 },
6583
),
6684
)
6785

68-
it.live("preserves mixed content ordering", () =>
86+
it.live("reports only the head when the line budget has no tail room", () =>
87+
withStore(
88+
(output) =>
89+
Effect.gen(function* () {
90+
const result = yield* output.truncate({ content: [{ type: "text", text: "short\nomitted" }] })
91+
expect(result.content).toEqual([
92+
{
93+
type: "text",
94+
text: expect.stringMatching(/^short\n\[showing lines 1-1 of 2; full output saved to .+\]$/),
95+
},
96+
])
97+
}),
98+
{ maxLines: 1, maxBytes: 1_000 },
99+
),
100+
)
101+
102+
it.live("keeps multibyte tails intact", () =>
103+
withStore(
104+
(output) =>
105+
Effect.gen(function* () {
106+
const result = yield* output.truncate({ content: [{ type: "text", text: "开头\n中间\n结尾🚀" }] })
107+
const text = result.content[0]?.type === "text" ? result.content[0].text : ""
108+
expect(text).toMatch(/^开头\n\[showing lines 1-1 and 3-3 of 3; full output saved to .+\]\n结尾🚀$/)
109+
expect(text).not.toContain("\uFFFD")
110+
}),
111+
{ maxLines: 2, maxBytes: 1_000 },
112+
),
113+
)
114+
115+
it.live("keeps attachments where they were in the content", () =>
69116
withStore(
70117
(output) =>
71118
Effect.gen(function* () {
72119
const file = { type: "file" as const, uri: "file:///image.png", mime: "image/png" }
73120
const result = yield* output.truncate({
74121
content: [{ type: "text", text: "before" }, file, { type: "text", text: "after\nomitted" }],
75122
})
123+
const outputPath = result.metadata?.outputPath
76124
expect(result.content).toEqual([
77-
{ type: "text", text: "before" },
125+
{ type: "text", text: `before\n[showing lines 1-1 and 3-3 of 3; full output saved to ${outputPath}]` },
78126
file,
79-
{ type: "text", text: "after" },
80-
{ type: "text", text: expect.stringMatching(/^\[showing lines 1-2 of 3; full output saved to /) },
127+
{ type: "text", text: "omitted" },
81128
])
82129
}),
83130
{ maxLines: 2, maxBytes: 1_000 },
84131
),
85132
)
86133

134+
it.live("gives unused tail budget back to the head", () =>
135+
withStore(
136+
(output) =>
137+
Effect.gen(function* () {
138+
const text = `${Array.from({ length: 30 }, (_, index) => `s${index + 1}`).join("\n")}\n${"x".repeat(2_000)}`
139+
const result = yield* output.truncate({ content: [{ type: "text", text }] })
140+
const preview = result.content[0]?.type === "text" ? result.content[0].text : ""
141+
expect(preview).toMatch(/\[showing lines 1-10 of 31; full output saved to .+\]$/)
142+
expect(preview.split("\n").filter((line) => line.startsWith("s"))).toHaveLength(10)
143+
}),
144+
{ maxLines: 10, maxBytes: 1_000 },
145+
),
146+
)
147+
148+
it.live("keeps content when the marker alone exceeds the byte limit", () =>
149+
withStore(
150+
(output) =>
151+
Effect.gen(function* () {
152+
const text = Array.from({ length: 20 }, () => "x".repeat(20)).join("\n")
153+
const result = yield* output.truncate({ content: [{ type: "text", text }] })
154+
const preview = result.content[0]?.type === "text" ? result.content[0].text : ""
155+
expect(preview.startsWith("x")).toBe(true)
156+
expect(preview).toContain("[showing lines ")
157+
}),
158+
{ maxLines: 10, maxBytes: 100 },
159+
),
160+
)
161+
162+
it.live("keeps text lines on the same side of an attachment", () =>
163+
withStore(
164+
(output) =>
165+
Effect.gen(function* () {
166+
const file = { type: "file" as const, uri: "file:///image.png", mime: "image/png" }
167+
const result = yield* output.truncate({
168+
content: [{ type: "text", text: "a1\na2\na3\na4\na5\na6" }, file, { type: "text", text: "b1" }],
169+
})
170+
const outputPath = result.metadata?.outputPath
171+
expect(result.content).toEqual([
172+
{
173+
type: "text",
174+
text: `a1\na2\n[showing lines 1-2 and 6-7 of 7; full output saved to ${outputPath}]\na6`,
175+
},
176+
file,
177+
{ type: "text", text: "b1" },
178+
])
179+
}),
180+
{ maxLines: 4, maxBytes: 1_000 },
181+
),
182+
)
183+
184+
it.live("keeps the preview within the byte limit", () =>
185+
withStore(
186+
(output) =>
187+
Effect.gen(function* () {
188+
const lines = Array.from({ length: 20 }, () => "x".repeat(100))
189+
const result = yield* output.truncate({ content: [{ type: "text", text: lines.join("\n") }] })
190+
const text = result.content[0]?.type === "text" ? result.content[0].text : ""
191+
expect(text).toContain("x")
192+
expect(text).toContain("[showing lines ")
193+
expect(Buffer.byteLength(text, "utf-8")).toBeLessThanOrEqual(400)
194+
}),
195+
{ maxLines: 1_000, maxBytes: 400 },
196+
),
197+
)
198+
199+
it.live("keeps a leading attachment ahead of the preview", () =>
200+
withStore(
201+
(output) =>
202+
Effect.gen(function* () {
203+
const file = { type: "file" as const, uri: "file:///image.png", mime: "image/png" }
204+
const result = yield* output.truncate({
205+
content: [file, { type: "text", text: "line1\nline2" }],
206+
})
207+
expect(result.content[0]).toBe(file)
208+
expect(result.content[1]).toEqual({
209+
type: "text",
210+
text: expect.stringMatching(/^line1\n\[showing lines 1-1 of 2; full output saved to .+\]$/),
211+
})
212+
}),
213+
{ maxLines: 1, maxBytes: 1_000 },
214+
),
215+
)
216+
87217
it.live("skips results that report a truncation state", () =>
88218
withStore((output) =>
89219
Effect.gen(function* () {
@@ -124,8 +254,7 @@ describe("ToolOutput", () => {
124254
Effect.gen(function* () {
125255
const result = yield* output.truncate({ content: [{ type: "text", text: "one\n" }] })
126256
expect(result.content).toEqual([
127-
{ type: "text", text: "one" },
128-
{ type: "text", text: expect.stringMatching(/^\[showing lines 1-1 of 1; full output saved to /) },
257+
{ type: "text", text: expect.stringMatching(/^\[showing lines 1-1 of 1; full output saved to .+\]\none$/) },
129258
])
130259
}),
131260
{ maxLines: 2, maxBytes: 3 },

0 commit comments

Comments
 (0)