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
2 changes: 1 addition & 1 deletion packages/core/src/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export { Agent } from "./agent"
export { Model } from "./model"
export { OpenCode } from "./opencode"
export { Session } from "./session"
export { Tool } from "./tool"
export * as Tool from "./tool"
export { Location } from "./location"
export { Prompt } from "../session/prompt"
export { AbsolutePath } from "../schema"
5 changes: 2 additions & 3 deletions packages/core/src/public/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,10 @@ import { SessionProjector } from "../session/projector"
import { SessionStore } from "../session/store"
import { ApplicationTools } from "../tool/application-tools"
import { Session } from "./session"
import { Tool } from "./tool"

export interface Interface {
readonly sessions: Session.Interface
readonly tools: Tool.Service
readonly tools: import("./tool").Service
}

/** Intentional public native API for Effect applications embedding OpenCode. */
Expand Down Expand Up @@ -88,7 +87,7 @@ export const layer = Layer.effect(
const tools = yield* ApplicationTools.Service
const validation = yield* SessionModelValidation
return Service.of({
tools: { attach: tools.attach },
tools: { register: tools.register },
sessions: {
create: (input) =>
sessions.create({
Expand Down
13 changes: 6 additions & 7 deletions packages/core/src/public/tool.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
export * as Tool from "./tool"

import { Effect, Scope } from "effect"
import type { NativeTool } from "../tool/native"

export { Failure, make } from "../tool/native"
export type { Any, Content, Context, Executable } from "../tool/native"
export { Failure, RegistrationError, make } from "../tool/tool"
export type { AnyTool, Content, Context, Tool } from "../tool/tool"

export interface Service {
/**
* Attach same-process tools to this OpenCode instance for the current Scope.
* Register same-process tools on this OpenCode instance for the current Scope.
* Location tools with the same name take precedence where they are installed.
* Closing the Scope removes the tools immediately, so calls that have not
* started settling may fail because the tool is no longer available.
*/
readonly attach: (tools: Readonly<Record<string, NativeTool.Any>>) => Effect.Effect<void, never, Scope.Scope>
readonly register: (
tools: Readonly<Record<string, import("../tool/tool").AnyTool>>,
) => Effect.Effect<void, import("../tool/tool").RegistrationError, Scope.Scope>
}
2 changes: 2 additions & 0 deletions packages/core/src/session/runner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
import { SessionRunnerModel } from "./model"
import type { SystemContext } from "../../system-context/index"
import type { SessionContextEpoch } from "../context-epoch"
import type { ToolOutputStore } from "../../tool-output-store"

export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
"SessionRunner.StepLimitExceededError",
Expand All @@ -24,6 +25,7 @@ export type RunError =
| StepLimitExceededError
| SystemContext.InitializationBlocked
| SessionContextEpoch.AgentReplacementBlocked
| ToolOutputStore.Error

/** Runs one local continuation from already-recorded Session history. */
export interface Interface {
Expand Down
30 changes: 16 additions & 14 deletions packages/core/src/session/runner/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { SystemContext } from "../../system-context/index"
import { SystemContextRegistry } from "../../system-context/registry"
import { SkillGuidance } from "../../skill/guidance"
import { ToolRegistry } from "../../tool/registry"
import { ToolOutputStore } from "../../tool-output-store"
import { SessionContextEpoch } from "../context-epoch"
import { SessionCompaction } from "../compaction"
import { SessionEvent } from "../event"
Expand Down Expand Up @@ -63,7 +64,7 @@ import { toLLMMessages } from "./to-llm-message"
* - [x] Authorize and execute recorded local calls through a core-owned registry hook.
* - [x] Persist typed success, failure, and provider-executed tool outcomes.
* - [x] Start each recorded local call eagerly and await all settlements before continuation.
* - [ ] Add scoped runtime context, progress updates, output truncation, attachment normalization,
* - [ ] Add scoped runtime context, progress updates, attachment normalization,
* plugins, and cancellation settlement.
* - [x] Reload projected history and start the next explicit provider turn after local tool results.
* - [x] Continue for durable user steering accepted during an active provider turn.
Expand Down Expand Up @@ -131,7 +132,7 @@ export const layer = Layer.effect(
}
})

const awaitToolFibers = (fibers: FiberSet.FiberSet<void, never>) =>
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))

// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
Expand Down Expand Up @@ -185,7 +186,7 @@ export const layer = Layer.effect(
session.location,
agent.id,
).pipe(retryAgentMismatch(promotion))
const toolFibers = yield* FiberSet.make<void, never>()
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
let needsContinuation = false
if (promotion) {
const cutoff = yield* SessionInput.latestSeq(db, session.id)
Expand All @@ -211,6 +212,7 @@ export const layer = Layer.effect(
const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message)
const toolMaterialization = yield* tools.materialize(agent.info?.permissions)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
Expand All @@ -219,7 +221,7 @@ export const layer = Layer.effect(
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: toLLMMessages(context, model),
tools: yield* tools.definitions(agent.info?.permissions),
tools: toolMaterialization.definitions,
})
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
return yield* Effect.die(rebuildPreparedTurn())
Expand Down Expand Up @@ -251,16 +253,16 @@ export const layer = Layer.effect(
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
yield* Effect.uninterruptibleMask((restore) =>
restore(tools.settle({ sessionID: session.id, agent: agent.id, call: event })).pipe(
Effect.catchCause((cause) => {
if (isQuestionRejected(cause) || Cause.hasInterrupts(cause)) return Effect.failCause(cause)
return Effect.succeed({
result: { type: "error" as const, value: String(Cause.squash(cause)) },
output: undefined,
outputPaths: [],
})
restore(
toolMaterialization.settle({
sessionID: session.id,
agent: agent.id,
assistantMessageID,
call: event,
}),
).pipe(
Effect.flatMap((settlement) =>
publish(
LLMEvent.toolResult({
Expand Down Expand Up @@ -322,8 +324,8 @@ export const layer = Layer.effect(
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
if (stream._tag === "Success" && !publisher.hasProviderError())
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
const attempt = stream._tag === "Failure" ? stream : settled
if (attempt._tag === "Failure") return yield* Effect.failCause(attempt.cause)
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
return !publisher.hasProviderError() && needsContinuation
}),
)
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/session/runner/publish-llm-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
}
})

const assistantMessageIDForTool = (callID: string) => {
const tool = tools.get(callID)
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(`Unknown tool call: ${callID}`)
}

const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
event: LLMEvent,
outputPaths: ReadonlyArray<string> = [],
Expand Down Expand Up @@ -408,5 +413,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
hasAssistantStarted: () => assistantMessageID !== undefined,
hasProviderError: () => providerFailed,
startAssistant,
assistantMessageID: assistantMessageIDForTool,
}
}
132 changes: 59 additions & 73 deletions packages/core/src/tool-output-store.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export * as ToolOutputStore from "./tool-output-store"

import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { Config } from "./config"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
Expand All @@ -11,27 +11,11 @@ import type { ToolOutput } from "@opencode-ai/llm"

export const MAX_LINES = 2_000
export const MAX_BYTES = 50 * 1024
export const MAX_INLINE_MEDIA_BYTES = 5 * 1024 * 1024
export const RETENTION = Duration.days(7)

export const MANAGED_DIRECTORY = "tool-output"

export interface WriteInput {
readonly sessionID: SessionSchema.ID
readonly toolCallID: string
readonly content: string
readonly mime?: string
readonly name?: string
}

export interface TruncateInput extends WriteInput {
readonly maxLines?: number
readonly maxBytes?: number
}

export type TruncateResult =
| { readonly content: string; readonly truncated: false }
| { readonly content: string; readonly truncated: true; readonly outputPath: string }

export interface BoundInput {
readonly sessionID: SessionSchema.ID
readonly toolCallID: string
Expand All @@ -43,11 +27,22 @@ export interface BoundResult {
readonly outputPaths: ReadonlyArray<string>
}

export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolOutputStore.StorageError", {
operation: Schema.Literals(["encode", "write"]),
cause: Schema.Defect,
}) {}

export class MediaLimitError extends Schema.TaggedErrorClass<MediaLimitError>()("ToolOutputStore.MediaLimitError", {
mime: Schema.String,
bytes: Schema.Int,
limit: Schema.Int,
}) {}

export type Error = StorageError | MediaLimitError

export interface Interface {
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
readonly write: (input: WriteInput) => Effect.Effect<string>
readonly truncate: (input: TruncateInput) => Effect.Effect<TruncateResult>
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult>
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult, Error>
readonly cleanup: () => Effect.Effect<void>
}

Expand Down Expand Up @@ -109,14 +104,19 @@ const boundedPreview = (text: string, marker: string, maxLines: number, maxBytes
return bounded.tail ? `${bounded.head}\n\n${marker}\n\n${bounded.tail}` : `${bounded.head}\n\n${marker}`
}

const lineCount = (text: string) => {
let count = 1
for (const char of text) if (char === "\n") count++
return count
}

export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const config = yield* Effect.serviceOption(Config.Service)
const directory = path.join(global.data, MANAGED_DIRECTORY)

const limits = Effect.fn("ToolOutputStore.limits")(function* () {
if (Option.isNone(config)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
const entries = yield* config.value.entries().pipe(Effect.catch(() => Effect.succeed([] as Config.Entry[])))
Expand All @@ -127,68 +127,54 @@ export const layer = Layer.effect(
return { maxLines: configured.max_lines ?? MAX_LINES, maxBytes: configured.max_bytes ?? MAX_BYTES }
})

const write = Effect.fn("ToolOutputStore.write")(function* (input: WriteInput) {
const write = Effect.fn("ToolOutputStore.write")(function* (content: string) {
const file = path.join(directory, `tool_${Identifier.ascending()}`)
yield* fs.ensureDir(directory).pipe(Effect.orDie)
yield* fs.writeFileString(file, input.content, { flag: "wx" }).pipe(Effect.orDie)
yield* fs.ensureDir(directory).pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
yield* fs
.writeFileString(file, content, { flag: "wx" })
.pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
return file
})

const truncate = Effect.fn("ToolOutputStore.truncate")(function* (input: TruncateInput) {
const configured = yield* limits()
const maxLines = input.maxLines ?? configured.maxLines
const maxBytes = input.maxBytes ?? configured.maxBytes
if (input.content.split("\n").length <= maxLines && Buffer.byteLength(input.content, "utf-8") <= maxBytes) {
return { content: input.content, truncated: false } as const
const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
const outputLimits = yield* limits()
const media = input.output.content.filter((item) => item.type === "file")
let mediaBytes = 0
for (const item of media) {
if (item.source.type !== "data") continue
mediaBytes += Buffer.byteLength(item.source.data, "utf-8")
if (mediaBytes > MAX_INLINE_MEDIA_BYTES)
return yield* new MediaLimitError({ mime: item.mime, bytes: mediaBytes, limit: MAX_INLINE_MEDIA_BYTES })
}
const outputPath = yield* write(input)
const marker = `... output truncated; full content saved to ${outputPath} ...`
return {
content: boundedPreview(input.content, marker, maxLines, maxBytes),
truncated: true,
outputPath,
} as const
})
const contextual = {
structured: media.length > 0 ? {} : input.output.structured,
content: input.output.content.filter((item) => item.type === "text"),
}
const encoded = yield* Effect.try({
try: () => JSON.stringify(contextual, null, 2),
catch: (cause) => new StorageError({ operation: "encode", cause }),
})
if (lineCount(encoded) <= outputLimits.maxLines && Buffer.byteLength(encoded, "utf-8") <= outputLimits.maxBytes)
return {
output: { structured: contextual.structured, content: input.output.content },
outputPaths: [],
}

const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
const text = input.output.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n\n")
const structured = yield* Effect.sync(() => JSON.stringify(input.output.structured)).pipe(
Effect.catch(() => Effect.succeed(String(input.output.structured))),
)
const content = text || input.output.content.length > 0 ? text : structured
if (content === undefined) return { output: input.output, outputPaths: [] }

const truncated = yield* truncate({
sessionID: input.sessionID,
toolCallID: input.toolCallID,
content,
mime: "text/plain",
name: `${input.toolCallID}.txt`,
}).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("Unable to retain complete tool output", cause).pipe(
Effect.andThen(limits()),
Effect.map(({ maxLines, maxBytes }) => {
const marker = "... output truncated; omitted content could not be retained ..."
return {
content: boundedPreview(content, marker, maxLines, maxBytes),
truncated: true as const,
}
}),
),
),
)
if (!truncated.truncated) return { output: input.output, outputPaths: [] }
const outputPath = yield* write(encoded)
const marker = `... output truncated; full content saved to ${outputPath} ...`

return {
output: {
structured: input.output.structured,
structured: {},
content: [
{ type: "text" as const, text: truncated.content },
...input.output.content.filter((item) => item.type === "file"),
{
type: "text" as const,
text: boundedPreview(encoded, marker, outputLimits.maxLines, outputLimits.maxBytes),
},
...media,
],
},
outputPaths: "outputPath" in truncated ? [truncated.outputPath] : [],
outputPaths: [outputPath],
}
})

Expand All @@ -207,7 +193,7 @@ export const layer = Layer.effect(
}
})

return Service.of({ limits, write, truncate, bound, cleanup })
return Service.of({ limits, bound, cleanup })
}),
)

Expand Down
Loading
Loading