Skip to content
Draft
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
3 changes: 3 additions & 0 deletions packages/core/src/plugin/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { Ripgrep } from "../ripgrep.js"
import { Schedule } from "../schedule.js"
import { Session } from "../session.js"
import { SessionCompaction } from "../session/compaction.js"
import { SessionStore } from "../session/store.js"
import { SessionInstructions } from "../session/instructions.js"
import { SubagentJob } from "../session/subagent-job.js"
import { Shell } from "../shell.js"
Expand Down Expand Up @@ -153,6 +154,7 @@ const services = [
Session.Service,
SessionCompaction.Service,
SessionInstructions.Service,
SessionStore.Service,
SubagentJob.Service,
Shell.Service,
ShellSelect.Service,
Expand Down Expand Up @@ -209,6 +211,7 @@ export const requirements = LayerNode.group([
Session.node,
SessionCompaction.node,
SessionInstructions.node,
SessionStore.node,
SubagentJob.node,
Shell.node,
ShellSelect.node,
Expand Down
33 changes: 28 additions & 5 deletions packages/core/src/session/runner/step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Snapshot } from "../../snapshot.js"
import { Tool } from "../../tool.js"
import { ToolOutput } from "../../tool-output.js"
import { QuestionTool } from "../../tool/plugin/question.js"
import { ReadRange } from "../../tool/read-range.js"
import { StepFailedError } from "../error.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
Expand Down Expand Up @@ -87,6 +88,8 @@ export const make = Effect.gen(function* () {
readonly call: ToolCall
readonly fiber: Fiber.Fiber<void, Permission.DeclinedError | QuestionTool.CancelledError>
}> = []
let rangeFailure: SessionError.Error | undefined
const rangeGates = new Set<string>()
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
const executeTool = (call: ToolCall) => {
if (input.prepared.request.toolChoice?.type === "none")
Expand All @@ -96,7 +99,10 @@ export const make = Effect.gen(function* () {
agent: input.agent,
messageID: input.assistantMessageID,
call,
progress: (update) => publisher.progress(call.id, update),
progress: (update) => {
if (ReadRange.isGate(update)) rangeGates.add(call.id)
return publisher.progress(call.id, update)
},
})
}

Expand Down Expand Up @@ -126,9 +132,11 @@ export const make = Effect.gen(function* () {
restore(executeTool(event)).pipe(
Effect.flatMap(toolOutput.truncate),
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
Effect.catchTag("Tool.Error", (error) =>
publisher.failTool(event.id, toSessionError(error), error.metadata).pipe(Effect.asVoid),
),
Effect.catchTag("Tool.Error", (error) => {
if (error instanceof ReadRange.BlockedError)
rangeFailure = { type: "doom_loop", message: error.message }
return publisher.failTool(event.id, toSessionError(error), error.metadata).pipe(Effect.asVoid)
}),
),
).pipe(Effect.forkScoped),
})
Expand All @@ -151,9 +159,19 @@ export const make = Effect.gen(function* () {
const joined = yield* restore(Fiber.awaitAll(toolRuns.map((run) => run.fiber))).pipe(Effect.exit)
if (Exit.isFailure(joined)) yield* interruptTools
const tools = classifyToolExits(joined, toolRuns)
if (
!input.isLocationClosed() &&
!streamInterrupted &&
!tools.interrupted &&
tools.declines.some(
(decline) => rangeGates.has(decline.call.id) && decline.reason instanceof Permission.DeclinedError,
)
)
rangeFailure = { type: "permission.rejected", message: "The user declined repeated out-of-range reads" }

const overflow = overflowFailure ?? streamFailure
if (
!rangeFailure &&
!publisher.record().outputStarted &&
isContextOverflowFailure(overflow) &&
(yield* restore(input.recoverOverflow))
Expand All @@ -167,6 +185,8 @@ export const make = Effect.gen(function* () {
}

if (overflowFailure) yield* publisher.publish(overflowFailure)
// All tool fibers and the provider stream have settled; flushing a failure here cannot race active fragments.
if (rangeFailure) yield* publisher.failAssistant(rangeFailure)
const recorded = publisher.record()
const unknownFinish =
Exit.isSuccess(stream) && recorded.finish?.finish === "unknown"
Expand All @@ -180,14 +200,15 @@ export const make = Effect.gen(function* () {
const llmFailure = streamFailure instanceof AIError ? streamFailure : unknownFinish
const llmError = llmFailure && !recorded.providerFailed ? toSessionError(llmFailure) : undefined
if (
!rangeFailure &&
input.recoverContinuation &&
llmFailure?.reason._tag === "Transport" &&
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
!recorded.outputStarted
)
return Outcome.RecoverFull()
const retry =
llmFailure && llmError && !isContextOverflowFailure(llmFailure)
!rangeFailure && llmFailure && llmError && !isContextOverflowFailure(llmFailure)
? yield* restore(
input.retry(
llmFailure,
Expand Down Expand Up @@ -259,6 +280,7 @@ export const make = Effect.gen(function* () {
}

if (
!rangeFailure &&
llmFailure &&
llmError &&
retry?.retry &&
Expand All @@ -268,6 +290,7 @@ export const make = Effect.gen(function* () {
)
return Outcome.Continue({ error: llmError, decision: retry })

if (rangeFailure) return yield* new StepFailedError({ error: rangeFailure })
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) {
if (input.isLocationClosed()) return Outcome.Completed({ needsContinuation: true })
Expand Down
27 changes: 22 additions & 5 deletions packages/core/src/tool/plugin/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { SessionInstructions } from "../../session/instructions.js"
import { AbsolutePath } from "../../schema.js"
import { ReadToolFileSystem } from "../read-filesystem.js"
import { Environment } from "../../environment/index.js"
import { ReadRange } from "../read-range.js"

export const name = "read"
const FILENAME = "AGENTS.md"
Expand All @@ -34,6 +35,7 @@ export const Plugin = {
const sessionInstructions = yield* SessionInstructions.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const checkRange = yield* ReadRange.make

yield* ctx.tool
.transform((editor) =>
Expand All @@ -47,10 +49,25 @@ export const Plugin = {
execute: (input, context) => {
return Effect.gen(function* () {
const read = (target: FileAccess.Target) =>
reader.read(target.absolute, target.resource, {
offset: input.offset,
limit: input.limit,
})
reader
.read(target.absolute, target.resource, {
offset: input.offset,
limit: input.limit,
})
.pipe(
Effect.catchTag("ReadTool.OffsetOutOfRangeError", (error) =>
Effect.gen(function* () {
if (error.version === undefined) return yield* error
const range = {
path: yield* fs.resolve(target.absolute),
version: error.version,
totalLines: error.lines,
}
yield* checkRange(range, context)
return yield* new ToolFailure({ message: error.message, error, metadata: { readRange: range } })
}),
),
)

const requested = yield* access.authorizeRead(input.path, context)
const result = yield* read(requested).pipe(
Expand Down Expand Up @@ -194,7 +211,7 @@ export const toModelContent = (path: string, offset: number | undefined, output:
const start = output.type === "text-page" ? output.offset : 1
// Pages already join selected lines; a trailing newline represents a selected blank line.
const text = output.type === "file" ? output.content.replace(/\n$/, "") : output.content
const lines = output.content === "" ? [] : text.split("\n")
const lines = (output.type === "text-page" ? output.lines === 0 : output.content === "") ? [] : text.split("\n")
const content = [
lines.length === 0 ? `Read file ${path}, 0 lines` : `Read file ${path}, lines ${start}-${start + lines.length - 1}`,
]
Expand Down
23 changes: 20 additions & 3 deletions packages/core/src/tool/read-filesystem.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * as ReadToolFileSystem from "./read-filesystem.js"

import path from "path"
import { createHash } from "crypto"
import { pathToFileURL } from "url"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
Expand Down Expand Up @@ -42,7 +43,7 @@ export class MediaIngestLimitError extends Schema.TaggedError<MediaIngestLimitEr

export class OffsetOutOfRangeError extends Schema.TaggedError<OffsetOutOfRangeError>()(
"ReadTool.OffsetOutOfRangeError",
{ offset: Schema.Number, lines: Schema.Number },
{ offset: Schema.Number, lines: Schema.Number, version: Schema.optionalKey(Schema.String) },
) {
override get message() {
return `Offset ${this.offset} is out of range for this file (${this.lines} ${this.lines === 1 ? "line" : "lines"})`
Expand Down Expand Up @@ -83,6 +84,7 @@ export class TextPage extends Schema.Class<TextPage>("ReadTool.TextPage")({
content: Schema.String,
mime: Schema.String,
offset: PositiveInt,
lines: NonNegativeInt.annotate({ description: "The number of selected lines, including blank lines." }),
truncated: Schema.Boolean,
next: Schema.optionalKey(PositiveInt),
}) {}
Expand Down Expand Up @@ -170,6 +172,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
result,
first.bytes.subarray(0, result.consumed).includes(0),
lineCount(textLeaf(first.bytes).summary.lines, first.bytes.at(-1)),
[first.bytes],
)
}

Expand Down Expand Up @@ -207,7 +210,14 @@ export const read = Effect.fn("ReadTool.read")(function* (
checked += leaf.summary.bytes
return length > 0 && leaf.bytes.subarray(0, length).includes(0)
})
return yield* makeTextPage(input, resource, translated, binary, lineCount(lines, leaves.at(-1)?.bytes.at(-1)))
return yield* makeTextPage(
input,
resource,
translated,
binary,
lineCount(lines, leaves.at(-1)?.bytes.at(-1)),
leaves.map((leaf) => leaf.bytes),
)
}
}

Expand Down Expand Up @@ -241,15 +251,22 @@ const makeTextPage = Effect.fnUntraced(function* (
result: NonNullable<ReturnType<typeof textPage>>,
binary: boolean,
lines: number,
chunks: ReadonlyArray<Uint8Array>,
) {
if (binary) return yield* new BinaryFileError({ resource })
if (result.entries.length === 0 && result.offset !== 1)
return yield* new OffsetOutOfRangeError({ offset: result.offset, lines })
return yield* new OffsetOutOfRangeError({
offset: result.offset,
lines,
// An invalid offset has already scanned to EOF. Fingerprint those bytes without rereading the file.
version: chunks.reduce((hash, bytes) => hash.update(bytes), createHash("sha256")).digest("hex"),
})
return new TextPage({
type: "text-page",
content: result.entries.join("\n"),
mime: mimeType(input),
offset: result.offset,
lines: result.entries.length,
truncated: result.next !== undefined,
...(result.next === undefined ? {} : { next: result.next }),
})
Expand Down
91 changes: 91 additions & 0 deletions packages/core/src/tool/read-range.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
export * as ReadRange from "./read-range.js"

import { ToolFailure } from "@opencode/ai/effect"
import type { Tool } from "@opencode/schema/tool"
import { Effect, Schema } from "effect"
import { SessionStore } from "../session/store.js"
import { Permission } from "../permission.js"

export interface Range {
readonly path: string
readonly version: string
readonly totalLines: number
}

/** A configured policy stop, distinct from an actual user declining a request. */
export class BlockedError extends ToolFailure {}

const isRange = Schema.is(Schema.Struct({ path: Schema.String, version: Schema.String, totalLines: Schema.Number }))
export const isGate = (metadata: Tool.Metadata) => metadata.readRangeGate === true && isRange(metadata.readRange)

export const make = Effect.gen(function* () {
const store = yield* SessionStore.Service
const permission = yield* Permission.Service

return Effect.fn("ReadRange.check")(function* (range: Range, context: Tool.Context) {
// Failure settlement snapshots progress even if the permission request is cancelled or declined.
yield* context.progress({ readRange: range })
const current = yield* store.messageWithRefs(context.messageID)
if (!current || current.sessionID !== context.sessionID) return
let messages = [current.message]
let cursor = context.messageID
let failures = 0
// Read canonical projections without hydrating media, including settled calls in this response.
// Page back to the promoted user boundary rather than imposing a fixed detection horizon.
while (failures < 2) {
for (const message of messages) {
if (message.type === "user") return
if (message.type !== "assistant") continue
failures += message.content.filter((part) => {
if (part.type !== "tool" || part.state.status !== "error") return false
const previous = part.state.metadata?.readRange
return (
isRange(previous) &&
previous.path === range.path &&
previous.version === range.version &&
previous.totalLines === range.totalLines
)
}).length
if (failures >= 2) break
}
if (failures >= 2) break
messages = yield* store
.messagesWithRefs({
sessionID: context.sessionID,
order: "desc",
limit: 16,
cursor: { id: cursor, direction: "next" },
})
.pipe(Effect.orDie)
const oldest = messages.at(-1)
if (!oldest) return
cursor = oldest.id
}
// Only completed failures count; calls already running in parallel may finish before a later call is gated.
yield* context.progress({ readRange: range, readRangeGate: true })
yield* permission
.assert({
action: "doom_loop",
resources: ["read"],
save: ["read"],
metadata: { tool: "read", readRange: range },
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
.pipe(
Effect.catchTag("Permission.BlockedError", (error) =>
Effect.fail(
new BlockedError({
message: `Repeated out-of-range reads stopped by doom_loop permission: ${range.path}`,
error,
metadata: { readRange: range, readRangeBlocked: true },
}),
),
),
Effect.catchTag("Permission.CorrectedError", (error) =>
Effect.fail(new ToolFailure({ message: error.message, error, metadata: { readRange: range } })),
),
)
})
})
1 change: 1 addition & 0 deletions packages/core/test/session-instructions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const readToolNode = makeLocationNode({
Image.node,
Permission.node,
SessionInstructions.node,
SessionStore.node,
FSUtil.node,
Location.node,
],
Expand Down
Loading
Loading