Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { LLMAISDK } from "./llm/ai-sdk"
import { LLMNativeRuntime } from "./llm/native-runtime"
import { LLMRequestPrep } from "./llm/request"
import { Truncate } from "@/tool/truncate"

export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX

Expand Down Expand Up @@ -70,6 +71,7 @@ const live: Layer.Layer<
| EventV2Bridge.Service
| LLMClientService
| RuntimeFlags.Service
| Truncate.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
Expand All @@ -81,6 +83,7 @@ const live: Layer.Layer<
const events = yield* EventV2Bridge.Service
const llmClient = yield* LLMClient.Service
const flags = yield* RuntimeFlags.Service
const truncate = yield* Truncate.Service

const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
yield* Effect.logInfo("stream", {
Expand Down Expand Up @@ -142,7 +145,8 @@ const live: Layer.Layer<
title: typeof result === "object" ? result?.title : undefined,
}
} catch (e: any) {
return { result: "", error: e.message ?? String(e) }
const error = await bridge.promise(truncate.output(e.message ?? String(e)))
Comment thread
MagMueller marked this conversation as resolved.
return { result: "", error: error.content }
}
}

Expand Down Expand Up @@ -301,11 +305,12 @@ const live: Layer.Layer<
toolName: lower,
}
}
const error = await bridge.promise(truncate.output(failed.error.message))
Comment thread
MagMueller marked this conversation as resolved.
return {
...failed.toolCall,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
error: error.content,
}),
toolName: "invalid",
}
Expand Down Expand Up @@ -398,6 +403,7 @@ export const node = LayerNode.make({
EventV2Bridge.node,
llmClient,
RuntimeFlags.node,
Truncate.node,
],
})

Expand Down
10 changes: 8 additions & 2 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Truncate } from "@/tool/truncate"

const parentTitlePrefix = "New session - "
const childTitlePrefix = "Child session - "
Expand Down Expand Up @@ -488,7 +489,7 @@ export type Patch = Omit<Partial<Info>, "time" | "share" | "summary" | "revert"
const layer: Layer.Layer<
Service,
never,
BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service
BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service | Truncate.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
Expand All @@ -497,6 +498,7 @@ const layer: Layer.Layer<
const background = yield* BackgroundJob.Service
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
const truncate = yield* Truncate.Service

const createNext = Effect.fn("Session.createNext")(function* (input: {
id?: SessionID
Expand Down Expand Up @@ -636,6 +638,10 @@ const layer: Layer.Layer<

const updatePart = <T extends SessionV1.Part>(part: T): Effect.Effect<T> =>
Effect.gen(function* () {
if (part.type === "tool" && part.state.status === "error") {
const bounded = yield* truncate.output(part.state.error)
part.state.error = bounded.content
}
yield* events.publish(SessionV1.Event.PartUpdated, {
sessionID: part.sessionID,
part: structuredClone(part),
Expand Down Expand Up @@ -1012,7 +1018,7 @@ function listByProject(
export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node],
deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node, Truncate.node],
})

export * as Session from "./session"
22 changes: 14 additions & 8 deletions packages/opencode/src/tool/truncate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Option, Schedule, Context } from "effect"
import { Cause, Duration, Effect, Exit, Layer, Option, Schedule, Context } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
import { FSUtil } from "@opencode-ai/core/fs-util"
Expand All @@ -13,11 +13,11 @@ import { TRUNCATION_DIR } from "./truncation-dir"
const RETENTION = Duration.days(7)

export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export const MAX_BYTES = 40 * 1024
export const DIR = TRUNCATION_DIR
export const GLOB = path.join(TRUNCATION_DIR, "*")

export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath?: string }

export interface Options {
maxLines?: number
Expand Down Expand Up @@ -124,19 +124,25 @@ const layer = Layer.effect(
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
const file = yield* write(text)
const saved = yield* write(text).pipe(Effect.exit)
const file = Exit.isSuccess(saved) ? saved.value : undefined
if (Exit.isFailure(saved)) {
yield* Effect.logWarning("failed to save full truncated tool response", { cause: Cause.pretty(saved.cause) })
}

const hint = hasTaskTool(agent)
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
const hint = file
? hasTaskTool(agent)
? `The tool response was truncated. Full content saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
: `The tool response was truncated. Full content saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
: "The tool response was truncated, and the full content could not be saved."

return {
content:
direction === "head"
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
truncated: true,
outputPath: file,
...(file ? { outputPath: file } : {}),
} as const
})

Expand Down
61 changes: 58 additions & 3 deletions packages/opencode/test/tool/truncation.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { describe, test, expect } from "bun:test"
import { JSONParseError } from "@ai-sdk/provider"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { filesystem } from "@opencode-ai/core/effect/app-node-platform"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect, FileSystem } from "effect"
import { Effect, FileSystem, Layer } from "effect"
import { Truncate } from "@/tool/truncate"
import { Config } from "@/config/config"
import { Identifier } from "../../src/id/id"
Expand All @@ -12,11 +13,26 @@ import path from "path"
import { testEffect } from "../lib/effect"
import { writeFileStringScoped } from "../lib/filesystem"
import { TestConfig } from "../fixture/config"
import { InvalidToolInputError } from "ai"

const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
const ROOT = path.resolve(import.meta.dir, "..", "..")

const it = testEffect(LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem])))
const failedWriteFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({
...fs,
writeFileString: () => Effect.die("blocked test write"),
}),
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const failedWriteIt = testEffect(
LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem]), [[FSUtil.node, failedWriteFS]]),
)

const configuredLayer = (cfg: ConfigV1.Info) =>
LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem, Config.node]), [
Expand Down Expand Up @@ -102,7 +118,7 @@ describe("Truncate", () => {

test("uses default MAX_LINES and MAX_BYTES", () => {
expect(Truncate.MAX_LINES).toBe(2000)
expect(Truncate.MAX_BYTES).toBe(50 * 1024)
expect(Truncate.MAX_BYTES).toBe(40 * 1024)
})

it.live("limits() falls back to MAX_LINES/MAX_BYTES when Config is not provided", () =>
Expand Down Expand Up @@ -180,7 +196,7 @@ describe("Truncate", () => {
const result = yield* svc.output(lines, { maxLines: 10 })

expect(result.truncated).toBe(true)
expect(result.content).toContain("The tool call succeeded but the output was truncated")
expect(result.content).toContain("The tool response was truncated")
expect(result.content).toContain("Grep")
if (!result.truncated) throw new Error("expected truncated")
expect(result.outputPath).toBeDefined()
Expand All @@ -192,6 +208,45 @@ describe("Truncate", () => {
}),
)

it.live("archives the production-shaped malformed tool error", () =>
Effect.gen(function* () {
const malformed =
'{"description":"Test resumed player extraction","code":"const x=1;' + "\n\t".repeat(118_000) + '"}'
let cause: unknown
try {
JSON.parse(malformed)
} catch (error) {
cause = error
}
if (!cause) throw new Error("expected malformed input to fail JSON parsing")

const failed = new InvalidToolInputError({
toolName: "browser_execute",
toolInput: malformed,
cause: new JSONParseError({ text: malformed, cause }),
})
const result = yield* (yield* Truncate.Service).output(failed.message)
expect(result.truncated).toBe(true)
if (!result.truncated || !result.outputPath) throw new Error("expected archived malformed-tool error")

const stored = JSON.stringify({ tool: "browser_execute", error: result.content })
expect(stored.length).toBeLessThanOrEqual(Truncate.MAX_BYTES)
expect(JSON.parse(stored).error).toContain(result.outputPath)
expect(yield* (yield* FSUtil.Service).readFileString(result.outputPath)).toBe(failed.message)
}),
)

failedWriteIt.live("keeps a bounded response when the full content cannot be saved", () =>
Effect.gen(function* () {
const result = yield* (yield* Truncate.Service).output("x".repeat(Truncate.MAX_BYTES + 1))

expect(result.truncated).toBe(true)
if (!result.truncated) throw new Error("expected truncated output")
expect(result.outputPath).toBeUndefined()
expect(result.content).toContain("full content could not be saved")
}),
)

it.live("suggests Task tool when agent has task permission", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
Expand Down
Loading