diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a99f8acff2..3fe9509015 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -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 @@ -70,6 +71,7 @@ const live: Layer.Layer< | EventV2Bridge.Service | LLMClientService | RuntimeFlags.Service + | Truncate.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -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", { @@ -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))) + return { result: "", error: error.content } } } @@ -301,11 +305,12 @@ const live: Layer.Layer< toolName: lower, } } + const error = await bridge.promise(truncate.output(failed.error.message)) return { ...failed.toolCall, input: JSON.stringify({ tool: failed.toolCall.toolName, - error: failed.error.message, + error: error.content, }), toolName: "invalid", } @@ -398,6 +403,7 @@ export const node = LayerNode.make({ EventV2Bridge.node, llmClient, RuntimeFlags.node, + Truncate.node, ], }) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index f8dbb57765..cef504785f 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -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 - " @@ -488,7 +489,7 @@ export type Patch = Omit, "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* () { @@ -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 @@ -636,6 +638,10 @@ const layer: Layer.Layer< const updatePart = (part: T): Effect.Effect => 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), @@ -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" diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 3a48c90a98..ab3d696c65 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -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" @@ -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 @@ -124,11 +124,17 @@ 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: @@ -136,7 +142,7 @@ const layer = Layer.effect( ? `${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 }) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index d575a58ffa..d842a47559 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -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" @@ -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]), [ @@ -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", () => @@ -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() @@ -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