refactor(core): consolidate tool architecture

This commit is contained in:
Dax Raad 2026-07-26 20:08:55 -04:00
commit 8db7487c89
466 changed files with 9405 additions and 11071 deletions

View file

@ -4,7 +4,7 @@ import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Mode
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Config } from "../config"
import { EventV2 } from "../event"
import { Bus } from "../bus"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
@ -15,7 +15,7 @@ import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { toSessionError } from "./to-session-error"
import { Token } from "../util/token"
import type { ModelV2 } from "../model"
import type { Info } from "../model"
import { SessionUsage } from "./usage"
const DEFAULT_BUFFER = 20_000
@ -62,7 +62,7 @@ type Settings = {
type Dependencies = {
readonly app: App.Info
readonly events: EventV2.Interface
readonly bus: Bus.Interface
readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
}
@ -74,7 +74,7 @@ export type AutoInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
readonly model: Model
readonly cost: ModelV2.Info["cost"]
readonly cost: Info["cost"]
}
export type ManualInput = {
@ -86,7 +86,7 @@ export type ManualInput = {
type Plan = {
readonly session: SessionSchema.Info
readonly model: Model
readonly cost: ModelV2.Info["cost"]
readonly cost: Info["cost"]
readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string
readonly recent: string
@ -103,7 +103,7 @@ export interface Interface {
readonly compactManual: (input: ManualInput) => Effect.Effect<Outcome>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionCompaction") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
const truncate = (value: string) =>
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
@ -233,11 +233,11 @@ const make = (dependencies: Dependencies) => {
readonly error: SessionError.Error
readonly inputID?: SessionMessage.ID
}) {
yield* dependencies.events.publish(SessionEvent.Compaction.Failed, input)
yield* dependencies.bus.publish(SessionEvent.Compaction.Failed, input)
return { status: "failed" as const, error: input.error }
})
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
sessionID: plan.session.id,
reason: plan.reason,
recent: plan.recent,
@ -249,7 +249,7 @@ const make = (dependencies: Dependencies) => {
let usage: SessionUsage.Recorded | undefined
const recordUsage = Effect.suspend(() =>
usage
? dependencies.events.publish(SessionEvent.UsageRecorded, {
? dependencies.bus.publish(SessionEvent.UsageRecorded, {
sessionID: plan.session.id,
source: "compaction",
...usage,
@ -274,7 +274,7 @@ const make = (dependencies: Dependencies) => {
}
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
return dependencies.bus.publish(SessionEvent.Compaction.Delta, {
sessionID: plan.session.id,
text: event.text,
})
@ -316,7 +316,7 @@ const make = (dependencies: Dependencies) => {
inputID: plan.inputID,
})
}
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
yield* dependencies.bus.publish(SessionEvent.Compaction.Ended, {
sessionID: plan.session.id,
reason: plan.reason,
text: summary,
@ -395,17 +395,17 @@ const make = (dependencies: Dependencies) => {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const config = yield* Config.Service
const models = yield* SessionRunnerModel.Service
const app = yield* App.Metadata
return make({ events, llm, models, config: settings(yield* config.entries()), app })
return make({ bus, llm, models, config: settings(yield* config.entries()), app })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, App.node],
deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node],
})

View file

@ -1,7 +1,7 @@
export * as SessionContext from "./context"
import { Context, Effect, Layer } from "effect"
import { AgentV2 } from "../agent"
import { Agent } from "../agent"
import { CodeModeInstructions } from "../codemode/instructions"
import { Database } from "../database/database"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@ -13,7 +13,7 @@ import { McpInstructions } from "../mcp/instructions"
import { PluginSupervisor } from "../plugin/supervisor"
import { ReferenceInstructions } from "../reference/instructions"
import { SkillInstructions } from "../skill/instructions"
import { ToolRegistry } from "../tool/registry"
import { Tool } from "../tool"
import { AgentNotFoundError } from "./error"
import { SessionHistory } from "./history"
import { InstructionEntry } from "./instruction-entry"
@ -24,18 +24,18 @@ import { SessionStore } from "./store"
export interface Selection {
readonly session: SessionSchema.Info
readonly agent: AgentV2.Selection & { readonly info: AgentV2.Info }
readonly agent: Agent.Selection & { readonly info: Agent.Info }
readonly instructions: Instructions.Instructions
readonly toolSet: ToolRegistry.ToolSet
readonly tools: Tool.Snapshot
}
export interface Loaded {
readonly session: SessionSchema.Info
readonly agent: AgentV2.Selection & { readonly info: AgentV2.Info }
readonly agent: Agent.Selection & { readonly info: Agent.Info }
readonly model: SessionRunnerModel.Resolved
readonly initial: string
readonly messages: ReadonlyArray<SessionMessage.Info>
readonly toolSet: ToolRegistry.ToolSet
readonly tools: Tool.Snapshot
}
/**
@ -52,12 +52,12 @@ export interface Interface {
}
/** Location-scoped model-context loader for durable Session Steps. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionContext") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionContext") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const agents = yield* Agent.Service
const builtins = yield* InstructionBuiltIns.Service
const db = (yield* Database.Service).db
const discovery = yield* InstructionDiscovery.Service
@ -69,7 +69,7 @@ const layer = Layer.effect(
const referenceInstructions = yield* ReferenceInstructions.Service
const skillInstructions = yield* SkillInstructions.Service
const store = yield* SessionStore.Service
const registry = yield* ToolRegistry.Service
const registry = yield* Tool.Service
const select = Effect.fn("SessionContext.select")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
@ -82,7 +82,7 @@ const layer = Layer.effect(
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
const loaded = yield* Effect.all(
{
toolSet: registry.snapshot(agent.info.permissions),
tools: registry.snapshot(agent.info.permissions),
builtins: builtins.load(sessionID),
discovery: discovery.load(),
skills: skillInstructions.load(agent),
@ -97,14 +97,14 @@ const layer = Layer.effect(
agent: { ...agent, info: agent.info },
instructions: Instructions.combine([
loaded.builtins,
CodeModeInstructions.make(loaded.toolSet.codeModeCatalog),
CodeModeInstructions.make(loaded.tools.codeModeCatalog),
loaded.discovery,
loaded.skills,
loaded.references,
loaded.mcp,
loaded.entries,
]),
toolSet: loaded.toolSet,
tools: loaded.tools,
}
})
@ -117,7 +117,7 @@ const layer = Layer.effect(
model,
initial: history.initial,
messages: history.entries.map((entry) => entry.message),
toolSet: selection.toolSet,
tools: selection.tools,
}
})
@ -129,7 +129,7 @@ export const node = makeLocationNode({
service: Service,
layer,
deps: [
AgentV2.node,
Agent.node,
Database.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
@ -141,6 +141,6 @@ export const node = makeLocationNode({
SessionRunnerModel.node,
SessionStore.node,
SkillInstructions.node,
ToolRegistry.node,
Tool.node,
],
})

View file

@ -1,7 +1,7 @@
export * as SessionExecution from "./execution"
import { Cause, Context, Effect, Exit, Layer } from "effect"
import { EventV2 } from "../event"
import { Bus } from "../bus"
import { LocationServiceMap } from "../location-service-map"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionEvent } from "./event"
@ -26,7 +26,7 @@ export interface Interface {
}
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionExecution") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionExecution") {}
type InterruptReason = "user" | "shutdown" | "superseded"
@ -44,7 +44,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
effect.pipe(
Effect.tapCause((cause) =>
@ -65,7 +65,7 @@ export const layer = Layer.effect(
started: (sessionID) =>
reportLifecycle(
sessionID,
events.publish(SessionEvent.Execution.Started, { sessionID }, clearSuspensionOnCommit(sessionID)),
bus.publish(SessionEvent.Execution.Started, { sessionID }, clearSuspensionOnCommit(sessionID)),
),
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
const session = yield* store.get(sessionID)
@ -86,14 +86,14 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const outcome = terminal(exit, reason)
if (outcome.type === "succeeded") {
yield* events.publish(SessionEvent.Execution.Succeeded, { sessionID }, clearSuspensionOnCommit(sessionID))
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, clearSuspensionOnCommit(sessionID))
return
}
if (outcome.type === "interrupted") {
yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason })
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason })
return
}
yield* events.publish(
yield* bus.publish(
SessionEvent.Execution.Failed,
{
sessionID,
@ -118,7 +118,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, LocationServiceMap.node, EventV2.node],
deps: [SessionStore.node, LocationServiceMap.node, Bus.node],
})
/** Low-level compatibility layer for callers that only need durable Session recording. */

View file

@ -19,7 +19,7 @@ export interface Interface {
* Restart continuity actions for the managed server. The service is inert until called: only the
* managed server invokes it, so default, embedded, and stdio servers never suspend or auto-resume.
*/
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRestart") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRestart") {}
export const layer = Layer.effect(
Service,

View file

@ -34,8 +34,8 @@ export const layer = Layer.effect(
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id)
? selection.session.id.slice(4)
: selection.session.id
const toolSet = selection.toolSet
const toolDefinitions = toolSet.definitions
const tools = selection.tools
const toolDefinitions = tools.definitions
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
const contextEvent = yield* hooks.trigger("session", "context", {
sessionID: selection.session.id,

View file

@ -18,4 +18,4 @@ export interface Interface {
}
/** Location-scoped transient generation from Session context. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionGenerate") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionGenerate") {}

View file

@ -1,11 +1,11 @@
import { DateTime, Schema } from "effect"
import { AgentV2 } from "../agent"
import { Agent } from "../agent"
import { Location } from "../location"
import { ModelV2 } from "../model"
import { ProjectV2 } from "../project"
import { ProviderV2 } from "../provider"
import { Model } from "../model"
import { Project } from "../project"
import { Provider } from "../provider"
import { AbsolutePath, RelativePath } from "../schema"
import { WorkspaceV2 } from "../workspace"
import { Workspace } from "../workspace"
import { SessionSchema } from "./schema"
import { SessionTable } from "./sql"
import { SessionMessage } from "./message"
@ -17,7 +17,7 @@ const decodeRevert = Schema.decodeUnknownSync(PersistedRevert)
export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
return SessionSchema.Info.make({
id: SessionSchema.ID.make(row.id),
projectID: ProjectV2.ID.make(row.project_id),
projectID: Project.ID.make(row.project_id),
title: row.title,
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
fork: row.fork_session_id
@ -26,12 +26,12 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
messageID: row.fork_message_id ? SessionMessage.ID.make(row.fork_message_id) : undefined,
}
: undefined,
agent: row.agent ? AgentV2.ID.make(row.agent) : undefined,
agent: row.agent ? Agent.ID.make(row.agent) : undefined,
model: row.model
? {
id: ModelV2.ID.make(row.model.id),
providerID: ProviderV2.ID.make(row.model.providerID),
variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
id: Model.ID.make(row.model.id),
providerID: Provider.ID.make(row.model.providerID),
variant: Model.VariantID.make(row.model.variant ?? "default"),
}
: undefined,
cost: Money.USD.make(row.cost),
@ -46,7 +46,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
},
location: Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
workspaceID: row.workspace_id ? Workspace.ID.make(row.workspace_id) : undefined,
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
revert: row.revert ? decodeRevert(row.revert) : undefined,

View file

@ -28,7 +28,7 @@ export interface Interface {
readonly load: (sessionID: SessionSchema.ID) => Effect.Effect<Instructions.Instructions>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/InstructionEntry") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstructionEntry") {}
const renderValue = (value: Schema.Json) => (typeof value === "string" ? value : JSON.stringify(value, null, 2))

View file

@ -3,11 +3,12 @@ export * as InstructionState from "./instruction-state"
import { and, asc, desc, eq, gt, inArray, lte, sql } from "drizzle-orm"
import { DateTime, Effect, Option, Schema } from "effect"
import type { Database } from "../database/database"
import { EventV2 } from "../event"
import { Bus } from "../bus"
import { EventTable } from "../event/sql"
import { Instructions } from "../instructions/index"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { Event } from "@opencode-ai/schema/event"
import { SessionSchema } from "./schema"
import { InstructionBlobTable, InstructionStateTable, SessionTable } from "./sql"
@ -39,11 +40,11 @@ export const observe = Effect.fn("InstructionState.observe")(function* (
export const commit = Effect.fn("InstructionState.commit")(function* (
db: DatabaseService,
events: EventV2.Interface,
bus: Bus.Interface,
observation: Observation,
) {
if (!observation.initial && Object.keys(observation.delta).length === 0) return
yield* events.publish(
yield* bus.publish(
SessionEvent.InstructionsUpdated,
{ sessionID: observation.sessionID, delta: observation.delta },
{
@ -56,11 +57,11 @@ export const commit = Effect.fn("InstructionState.commit")(function* (
export const prepare = Effect.fn("InstructionState.prepare")(function* (
db: DatabaseService,
events: EventV2.Interface,
bus: Bus.Interface,
instructions: Instructions.Instructions,
sessionID: SessionSchema.ID,
) {
yield* commit(db, events, yield* observe(db, instructions, sessionID))
yield* commit(db, bus, yield* observe(db, instructions, sessionID))
})
export const apply = Effect.fn("InstructionState.apply")(function* (
@ -171,7 +172,7 @@ const assembleState = Effect.fnUntraced(function* (
result.push({
seq: update.row.seq,
message: SessionMessage.System.make({
id: SessionMessage.ID.fromEvent(EventV2.ID.make(update.row.id)),
id: SessionMessage.ID.fromEvent(Event.ID.make(update.row.id)),
type: "system",
text,
time: { created: DateTime.makeUnsafe(update.row.created) },
@ -310,16 +311,16 @@ function requireBlob(blobs: ReadonlyMap<Instructions.Hash, Schema.Json>, hash: I
return value
}
const instructionEventType = EventV2.versionedType(
const instructionEventType = Bus.versionedType(
SessionEvent.InstructionsUpdated.type,
SessionEvent.InstructionsUpdated.durable.version,
)
const compactionEventType = EventV2.versionedType(
const compactionEventType = Bus.versionedType(
SessionEvent.Compaction.Ended.type,
SessionEvent.Compaction.Ended.durable.version,
)
const movedEventType = EventV2.versionedType(SessionEvent.Moved.type, SessionEvent.Moved.durable.version)
const revertedEventType = EventV2.versionedType(
const movedEventType = Bus.versionedType(SessionEvent.Moved.type, SessionEvent.Moved.durable.version)
const revertedEventType = Bus.versionedType(
SessionEvent.RevertEvent.Committed.type,
SessionEvent.RevertEvent.Committed.durable.version,
)

View file

@ -3,7 +3,7 @@ export * as SessionInstructions from "./instructions"
import { relative } from "path"
import { Context, DateTime, Effect, Layer, Option, Ref, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { EventV2 } from "../event"
import { Bus } from "../bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { SessionEvent } from "./event"
@ -23,12 +23,12 @@ export interface Interface {
}) => Effect.Effect<void, MessageDecodeError | FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionInstructions") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionInstructions") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const store = yield* SessionStore.Service
const location = yield* Location.Service
@ -67,12 +67,12 @@ const layer = Layer.effect(
)
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
if (readable.length === 0) return
// Publish directly rather than through SessionV2.synthetic: a Location-scoped layer
// cannot depend on SessionV2 (it routes through LocationServiceMap, forming a type
// Publish directly rather than through Session.synthetic: a Location-scoped layer
// cannot depend on Session (it routes through LocationServiceMap, forming a type
// cycle with this node). The durable publish is what makes the synthetic visible on
// the next projected history reload. The dedup ledger lives on the synthetic message
// metadata so it survives across Location layer restarts.
yield* events.publish(SessionEvent.Synthetic, {
yield* bus.publish(SessionEvent.Synthetic, {
sessionID: input.sessionID,
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
@ -109,5 +109,5 @@ function describePath(root: string, path: string) {
export const node = makeLocationNode({
name: "session-instructions",
layer,
deps: [EventV2.node, FSUtil.node, Location.node, SessionStore.node],
deps: [Bus.node, FSUtil.node, Location.node, SessionStore.node],
})

View file

@ -1,16 +1,16 @@
export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest, type ToolContent } from "@opencode-ai/ai"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { Content } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Context, Effect, Layer, Result } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app"
import { ModelV2 } from "../model"
import { PermissionV2 } from "../permission"
import { Model } from "../model"
import { Permission } from "../permission"
import { PluginHooks } from "../plugin/hooks"
import { QuestionTool } from "../tool/question"
import { ToolOutputStore } from "../tool-output-store"
import { ToolRegistry } from "../tool/registry"
import { QuestionTool } from "../tool/plugin/question"
import { Tool } from "../tool"
import { SessionContext } from "./context"
import { SessionModelHeaders } from "./model-headers"
import { MAX_STEPS_PROMPT } from "./runner/max-steps"
@ -18,16 +18,16 @@ import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
export type ExecuteError = ToolOutputStore.Error | PermissionV2.DeclinedError | QuestionTool.CancelledError
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
// User declines dive under the leaves' blanket `mapError` as defects (the deliberate
// tunnel entered in PermissionV2.assert and the question tool), so a user's "no" can
// tunnel entered in Permission.assert and the question tool), so a user's "no" can
// never become model-facing tool output. They resurface as typed failures exactly once,
// here at the seam the runner executes through.
const declineDefect = (cause: Cause.Cause<ToolOutputStore.Error>) => {
const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
const decline = cause.reasons.flatMap((reason) =>
Cause.isDieReason(reason) &&
(reason.defect instanceof PermissionV2.DeclinedError || reason.defect instanceof QuestionTool.CancelledError)
(reason.defect instanceof Permission.DeclinedError || reason.defect instanceof QuestionTool.CancelledError)
? [reason.defect]
: [],
)[0]
@ -41,8 +41,8 @@ interface Prepared {
* step-limit-violating calls fail individually through the same seam.
*/
readonly executeTool: (
input: ToolRegistry.ExecuteInput,
) => Effect.Effect<ToolRegistry.ToolOutcome, ExecuteError>
input: Parameters<Tool.Snapshot["execute"]>[0],
) => Effect.Effect<Tool.Result, ExecuteError>
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */
readonly stepLimitReached: boolean
}
@ -59,7 +59,7 @@ const mimeToModality = (mime: string) => {
if (mime === "application/pdf") return "pdf"
}
const unsupportedMedia = (mime: string, name: string | undefined, capabilities: ModelV2.Capabilities) => {
const unsupportedMedia = (mime: string, name: string | undefined, capabilities: Model.Capabilities) => {
const modality = mimeToModality(mime)
if (!modality || capabilities.input.some((item) => item.startsWith(modality))) return
return {
@ -68,7 +68,7 @@ const unsupportedMedia = (mime: string, name: string | undefined, capabilities:
}
}
export const unsupportedParts = (messages: LLMRequest["messages"], capabilities: ModelV2.Capabilities) =>
export const unsupportedParts = (messages: LLMRequest["messages"], capabilities: Model.Capabilities) =>
messages.map((message) =>
Message.make({
...message,
@ -81,7 +81,7 @@ export const unsupportedParts = (messages: LLMRequest["messages"], capabilities:
...part,
result: {
...part.result,
value: part.result.value.map((item: ToolContent) => {
value: part.result.value.map((item: Content) => {
if (item.type !== "file") return item
return unsupportedMedia(item.mime, item.name, capabilities) ?? item
}),
@ -102,7 +102,7 @@ export interface Interface {
}
/** Location-scoped outbound model-request preparation. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionModelRequest") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionModelRequest") {}
export const layer = Layer.effect(
Service,
@ -119,14 +119,14 @@ export const layer = Layer.effect(
const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps
// The final Step keeps definitions available to protocols with native "none",
// preserving their prompt cache prefix. Calls are still rejected at execution.
const toolSet = input.context.toolSet
const tools = input.context.tools
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial]
.filter((part) => part.length > 0)
.map(SystemPart.make)
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history
const toolDefinitions = toolSet.definitions
const toolDefinitions = tools.definitions
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
// Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit.
const contextEvent = yield* hooks.trigger("session", "context", {
@ -142,7 +142,7 @@ export const layer = Layer.effect(
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
const registered = toolsByName.get(name)
return registered
? [Object.assign({}, registered, { description: tool.description, inputSchema: tool.input })]
? [{ ...registered, description: tool.description, inputSchema: tool.input }]
: []
})
const request = LLM.request({
@ -158,16 +158,10 @@ export const layer = Layer.effect(
})
const executeTool: Prepared["executeTool"] = (executeInput) => {
if (stepLimitReached)
return Effect.succeed({
status: "error",
error: { type: "tool.execution", message: "Tools are disabled after the maximum agent steps" },
})
return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
if (toolsByName.has(executeInput.call.name) && !Object.hasOwn(contextEvent.tools, executeInput.call.name))
return Effect.succeed({
status: "error",
error: { type: "tool.unknown", message: `Tool is not available for this request: ${executeInput.call.name}` },
})
return toolSet
return new Tool.Error({ message: `Tool is not available for this request: ${executeInput.call.name}` })
return tools
.execute(executeInput)
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
}

View file

@ -14,7 +14,7 @@ import {
} from "@opencode-ai/schema/session-pending"
import { Event } from "@opencode-ai/schema/event"
import type { Database } from "../database/database"
import type { EventV2 } from "../event"
import { Bus } from "../bus"
import { EventTable } from "../event/sql"
import { KeyedMutex } from "../effect/keyed-mutex"
import { SessionEvent } from "./event"
@ -38,7 +38,7 @@ const encodeUser = Schema.encodeSync(UserData)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeAdmittedEvent = Schema.decodeUnknownOption(SessionEvent.InputAdmitted.data)
const admittedEventType = Event.versionedType(
const admittedEventType = Bus.versionedType(
SessionEvent.InputAdmitted.type,
SessionEvent.InputAdmitted.durable.version,
)
@ -150,7 +150,7 @@ const promotedFromHistory = Effect.fn("SessionPending.promotedFromHistory")(func
export const admit = Effect.fn("SessionPending.admit")(function* (
db: DatabaseService,
events: EventV2.Interface,
bus: Bus.Interface,
request: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
@ -164,7 +164,7 @@ export const admit = Effect.fn("SessionPending.admit")(function* (
}
const promoted = yield* promotedFromHistory(db, request.sessionID, request.id)
if (promoted !== undefined) return promoted
return yield* events
return yield* bus
.publish(SessionEvent.InputAdmitted, {
inputID: request.id,
sessionID: request.sessionID,
@ -198,7 +198,7 @@ export const admit = Effect.fn("SessionPending.admit")(function* (
export const admitCompaction = Effect.fn("SessionPending.admitCompaction")(function* (
db: DatabaseService,
events: EventV2.Interface,
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) {
return yield* inboxLocks.withLock(input.sessionID)(
@ -210,7 +210,7 @@ export const admitCompaction = Effect.fn("SessionPending.admitCompaction")(funct
}
const pending = yield* compaction(db, input.sessionID)
if (pending) return pending
return yield* events
return yield* bus
.publish(SessionEvent.Compaction.Admitted, {
inputID: input.id,
sessionID: input.sessionID,
@ -413,7 +413,7 @@ export const equivalent = (
const publish = Effect.fn("SessionPending.publish")(function* (
db: DatabaseService,
events: EventV2.Interface,
bus: Bus.Interface,
sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionPendingTable.$inferSelect>,
) {
@ -423,7 +423,7 @@ const publish = Effect.fn("SessionPending.publish")(function* (
(row) => {
const entry = fromRow(row)
if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
return events
return bus
.publish(SessionEvent.InputPromoted, {
sessionID,
inputID: entry.id,
@ -450,7 +450,7 @@ const publish = Effect.fn("SessionPending.publish")(function* (
*/
export const promote = Effect.fn("SessionPending.promote")(function* (
db: DatabaseService,
events: EventV2.Interface,
bus: Bus.Interface,
sessionID: SessionSchema.ID,
scope: Promotable,
) {
@ -464,7 +464,7 @@ export const promote = Effect.fn("SessionPending.promote")(function* (
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie)
if (steers.length > 0 || scope === "steer") return yield* publish(db, events, sessionID, steers)
if (steers.length > 0 || scope === "steer") return yield* publish(db, bus, sessionID, steers)
const queued = yield* db
.select()
@ -475,7 +475,7 @@ export const promote = Effect.fn("SessionPending.promote")(function* (
.get()
.pipe(Effect.orDie)
if (!queued) return 0
const promoted = yield* publish(db, events, sessionID, [queued])
const promoted = yield* publish(db, bus, sessionID, [queued])
const arrivedSteers = yield* db
.select()
.from(SessionPendingTable)
@ -483,7 +483,7 @@ export const promote = Effect.fn("SessionPending.promote")(function* (
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie)
return promoted + (yield* publish(db, events, sessionID, arrivedSteers))
return promoted + (yield* publish(db, bus, sessionID, arrivedSteers))
}),
)
})

View file

@ -3,16 +3,16 @@ export * as SessionProjector from "./projector"
import { and, asc, desc, eq, gt, gte, inArray, lt, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { Bus } from "../bus"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { ModelV2 } from "../model"
import { Model } from "../model"
import { SessionEvent } from "./event"
import { SessionV1 } from "../v1/session"
import { WorkspaceTable } from "../control-plane/workspace.sql"
import { SessionMessage } from "./message"
import { SessionMessageUpdater } from "./message-updater"
import { SessionPending } from "./pending"
import { WorkspaceV2 } from "../workspace"
import { Workspace } from "../workspace"
import { InstructionState } from "./instruction-state"
import { MessageTable, PartTable, SessionPendingTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema"
@ -134,7 +134,7 @@ function applyUsage(
const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function* (
db: DatabaseService,
events: EventV2.Interface,
bus: Bus.Interface,
sessionID: (typeof SessionEvent.Step.Ended.Type)["data"]["sessionID"],
) {
const row = yield* db
@ -151,7 +151,7 @@ const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function*
.get()
.pipe(Effect.orDie)
if (!row) return
yield* events.publish(SessionEvent.UsageUpdated, {
yield* bus.publish(SessionEvent.UsageUpdated, {
sessionID,
cost: Money.USD.make(row.cost),
tokens: {
@ -314,7 +314,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
cursor = rows.at(-1)!.seq
}
yield* EventV2.reserveSequence(db, event.data.sessionID, event.data.parentSeq)
yield* Bus.reserveSequence(db, event.data.sessionID, event.data.parentSeq)
yield* InstructionState.rebuild(db, event.data.sessionID)
})
@ -349,7 +349,7 @@ function run(db: DatabaseService, event: MessageEvent) {
.get()
.pipe(
Effect.orDie,
Effect.map((row) => (row?.model ? Schema.decodeUnknownSync(ModelV2.Ref)(row.model) : undefined)),
Effect.map((row) => (row?.model ? Schema.decodeUnknownSync(Model.Ref)(row.model) : undefined)),
)
},
getCurrentAssistant() {
@ -460,9 +460,9 @@ function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, me
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
yield* events.project(SessionV1.Event.Created, (event) =>
yield* bus.project(SessionV1.Event.Created, (event) =>
Effect.gen(function* () {
const stored = yield* db
.insert(SessionTable)
@ -482,7 +482,7 @@ const layer = Layer.effectDiscard(
}
}),
)
yield* events.project(SessionV1.Event.Updated, (event) =>
yield* bus.project(SessionV1.Event.Updated, (event) =>
db
.update(SessionTable)
.set(sessionRow(event.data.info))
@ -490,7 +490,7 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* events.project(SessionEvent.Moved, (event) =>
yield* bus.project(SessionEvent.Moved, (event) =>
Effect.gen(function* () {
yield* db
.update(SessionTable)
@ -498,7 +498,7 @@ const layer = Layer.effectDiscard(
directory: event.data.location.directory,
path: event.data.subpath,
...(event.data.projectID ? { project_id: event.data.projectID } : {}),
workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null,
workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null,
time_updated: DateTime.toEpochMillis(event.created),
})
.where(eq(SessionTable.id, event.data.sessionID))
@ -507,13 +507,13 @@ const layer = Layer.effectDiscard(
yield* InstructionState.reset(db, event.data.sessionID)
}),
)
yield* events.project(SessionV1.Event.Deleted, (event) =>
yield* bus.project(SessionV1.Event.Deleted, (event) =>
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
)
yield* events.project(SessionEvent.Deleted, (event) =>
yield* bus.project(SessionEvent.Deleted, (event) =>
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
)
yield* events.project(SessionV1.Event.MessageUpdated, (event) =>
yield* bus.project(SessionV1.Event.MessageUpdated, (event) =>
Effect.gen(function* () {
const time_created = event.data.info.time.created
const id = event.data.info.id
@ -527,7 +527,7 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
yield* events.project(SessionV1.Event.MessageRemoved, (event) =>
yield* bus.project(SessionV1.Event.MessageRemoved, (event) =>
Effect.gen(function* () {
const rows = yield* db
.select()
@ -546,7 +546,7 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
yield* events.project(SessionV1.Event.PartRemoved, (event) =>
yield* bus.project(SessionV1.Event.PartRemoved, (event) =>
Effect.gen(function* () {
const row = yield* db
.select()
@ -563,7 +563,7 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
yield* events.project(SessionV1.Event.PartUpdated, (event) =>
yield* bus.project(SessionV1.Event.PartUpdated, (event) =>
Effect.gen(function* () {
const id = event.data.part.id
const messageID = event.data.part.messageID
@ -582,7 +582,7 @@ const layer = Layer.effectDiscard(
if (next) yield* applyUsage(db, sessionID, next)
}),
)
yield* events.project(SessionEvent.AgentSelected, (event) =>
yield* bus.project(SessionEvent.AgentSelected, (event) =>
db
.update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
@ -590,7 +590,7 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
)
yield* events.project(SessionEvent.ModelSelected, (event) =>
yield* bus.project(SessionEvent.ModelSelected, (event) =>
Effect.gen(function* () {
yield* run(db, event)
yield* db
@ -601,7 +601,7 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
yield* events.project(SessionEvent.Renamed, (event) =>
yield* bus.project(SessionEvent.Renamed, (event) =>
db
.update(SessionTable)
.set({ title: event.data.title, time_updated: DateTime.toEpochMillis(event.created) })
@ -609,9 +609,9 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* events.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* events.project(SessionEvent.InputPromoted, (event) =>
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* bus.project(SessionEvent.InputPromoted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
@ -643,7 +643,7 @@ const layer = Layer.effectDiscard(
)
}),
)
yield* events.project(SessionEvent.InputAdmitted, (event) =>
yield* bus.project(SessionEvent.InputAdmitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
@ -656,7 +656,7 @@ const layer = Layer.effectDiscard(
})
}),
)
yield* events.project(SessionEvent.Compaction.Admitted, (event) =>
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
@ -668,42 +668,42 @@ const layer = Layer.effectDiscard(
})
}),
)
yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
yield* events.project(SessionEvent.Execution.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
yield* events.project(SessionEvent.InstructionsUpdated, (event) =>
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta),
)
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Skill.Activated, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Ended, (event) =>
yield* bus.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* bus.project(SessionEvent.Skill.Activated, (event) => run(db, event))
yield* bus.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* bus.project(SessionEvent.Shell.Ended, (event) => run(db, event))
yield* bus.project(SessionEvent.Step.Started, (event) => run(db, event))
yield* bus.project(SessionEvent.Step.Ended, (event) =>
Effect.gen(function* () {
yield* run(db, event)
yield* applyUsage(db, event.data.sessionID, event.data)
}),
)
yield* events.project(SessionEvent.Step.Failed, (event) =>
yield* bus.project(SessionEvent.Step.Failed, (event) =>
Effect.gen(function* () {
yield* run(db, event)
if (event.data.cost !== undefined && event.data.tokens !== undefined)
yield* applyUsage(db, event.data.sessionID, { cost: event.data.cost, tokens: event.data.tokens })
}),
)
yield* events.project(SessionEvent.Text.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Input.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Called, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Success, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.RetryScheduled, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) =>
yield* bus.project(SessionEvent.Text.Started, (event) => run(db, event))
yield* bus.project(SessionEvent.Text.Ended, (event) => run(db, event))
yield* bus.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
yield* bus.project(SessionEvent.Tool.Input.Ended, (event) => run(db, event))
yield* bus.project(SessionEvent.Tool.Called, (event) => run(db, event))
yield* bus.project(SessionEvent.Tool.Success, (event) => run(db, event))
yield* bus.project(SessionEvent.Tool.Failed, (event) => run(db, event))
yield* bus.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
yield* bus.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
yield* bus.project(SessionEvent.RetryScheduled, (event) => run(db, event))
yield* bus.project(SessionEvent.Compaction.Started, (event) => run(db, event))
yield* bus.project(SessionEvent.Compaction.Ended, (event) =>
Effect.gen(function* () {
yield* run(db, event)
yield* InstructionState.advanceEpoch(db, event.data.sessionID, event.durable.seq)
@ -713,7 +713,7 @@ const layer = Layer.effectDiscard(
yield* SessionPending.settleCompaction(db, { sessionID: event.data.sessionID })
}),
)
yield* events.project(SessionEvent.Compaction.Failed, (event) =>
yield* bus.project(SessionEvent.Compaction.Failed, (event) =>
Effect.gen(function* () {
yield* run(db, event)
if (event.durable === undefined)
@ -722,7 +722,7 @@ const layer = Layer.effectDiscard(
yield* SessionPending.settleCompaction(db, { sessionID: event.data.sessionID })
}),
)
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
yield* bus.project(SessionEvent.RevertEvent.Staged, (event) =>
Effect.gen(function* () {
const revert = event.data.revert
yield* db
@ -736,7 +736,7 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
yield* events.project(SessionEvent.RevertEvent.Cleared, (event) =>
yield* bus.project(SessionEvent.RevertEvent.Cleared, (event) =>
db
.update(SessionTable)
.set({ revert: null, time_updated: DateTime.toEpochMillis(event.created) })
@ -744,7 +744,7 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie, Effect.asVoid),
)
yield* events.project(SessionEvent.RevertEvent.Committed, (event) =>
yield* bus.project(SessionEvent.RevertEvent.Committed, (event) =>
Effect.gen(function* () {
const boundary = yield* db
.select({ seq: SessionMessageTable.seq })
@ -781,18 +781,18 @@ const layer = Layer.effectDiscard(
yield* InstructionState.reset(db, event.data.sessionID)
}),
)
yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed, SessionEvent.UsageRecorded]).pipe(
yield* bus.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed, SessionEvent.UsageRecorded]).pipe(
Stream.runForEach((event) => {
if (
event.type === SessionEvent.Step.Failed.type &&
(event.data.cost === undefined || event.data.tokens === undefined)
)
return Effect.void
return publishSessionUsage(db, events, event.data.sessionID)
return publishSessionUsage(db, bus, event.data.sessionID)
}),
Effect.forkScoped({ startImmediately: true }),
)
}),
)
export const node = makeGlobalNode({ name: "session-projector", layer, deps: [EventV2.node, Database.node] })
export const node = makeGlobalNode({ name: "session-projector", layer, deps: [Bus.node, Database.node] })

View file

@ -3,7 +3,7 @@ export * as SessionRevert from "./revert"
import { and, asc, eq, gt } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { Bus } from "../bus"
import { RelativePath } from "../schema"
import { Snapshot } from "../snapshot"
import { SessionEvent } from "./event"
@ -63,7 +63,7 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
readonly files?: boolean
}) {
const snapshot = yield* Snapshot.Service
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const original = input.session.revert?.snapshot
? Snapshot.ID.make(input.session.revert.snapshot)
: yield* snapshot.capture()
@ -83,7 +83,7 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
snapshot: original,
files,
} satisfies SessionSchema.Info["revert"]
yield* events.publish(SessionEvent.RevertEvent.Staged, {
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID: input.session.id,
revert,
})
@ -98,16 +98,16 @@ export const clear = Effect.fn("SessionRevert.clear")(function* (session: Sessio
yield* snapshot.restore({
files: new Map((session.revert.files ?? []).map((file) => [RelativePath.make(file.file), original])),
})
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Cleared, {
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.RevertEvent.Cleared, {
sessionID: session.id,
})
})
export const commit = Effect.fn("SessionRevert.commit")(function* (session: SessionSchema.Info) {
if (!session.revert) return
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Committed, {
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.RevertEvent.Committed, {
sessionID: session.id,
to: session.revert.messageID,
})

View file

@ -6,7 +6,6 @@ import { SessionSchema } from "../schema"
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error"
import { SessionRunnerModel } from "./model"
import type { Instructions } from "../../instructions/index"
import type { ToolOutputStore } from "../../tool-output-store"
export type RunError =
| LLMError
@ -16,7 +15,6 @@ export type RunError =
| StepFailedError
| UserInterruptedError
| Instructions.InitializationBlocked
| ToolOutputStore.Error
/** Runs one local continuation from already-recorded Session history. */
export interface Interface {
@ -27,4 +25,4 @@ export interface Interface {
}) => Effect.Effect<void, RunError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunner") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRunner") {}

View file

@ -3,10 +3,9 @@ export * as SessionRunnerLLM from "./llm"
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent, type ToolCall } from "@opencode-ai/ai"
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { PermissionV2 } from "../../permission"
import { QuestionTool } from "../../tool/question"
import { ToolOutputStore } from "../../tool-output-store"
import { Bus } from "../../bus"
import { Permission } from "../../permission"
import { QuestionTool } from "../../tool/plugin/question"
import { InstructionState } from "../instruction-state"
import { SessionCompaction } from "../compaction"
import { SessionContext } from "../context"
@ -38,8 +37,8 @@ const CallOutcome = Data.taggedEnum<CallOutcome>()
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
const isDecline = (
error: SessionModelRequest.ExecuteError,
): error is PermissionV2.DeclinedError | QuestionTool.CancelledError =>
error._tag === "PermissionV2.DeclinedError" || error._tag === "QuestionTool.CancelledError"
): error is Permission.DeclinedError | QuestionTool.CancelledError =>
error._tag === "Permission.DeclinedError" || error._tag === "QuestionTool.CancelledError"
/**
* Classifies how the owned tool fibers ended. Interrupts abort the step; a user decline
@ -66,8 +65,8 @@ const classifyToolExits = (
// drain's error channel never carries a decline.
const failure = causes.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
const reasons = cause.reasons.flatMap((reason): Array<Cause.Reason<ToolOutputStore.Error>> =>
Cause.isFailReason(reason) ? (isDecline(reason.error) ? [] : [Cause.makeFailReason(reason.error)]) : [reason],
const reasons = cause.reasons.flatMap((reason): Array<Cause.Reason<never>> =>
Cause.isFailReason(reason) ? [] : [reason],
)
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
}).at(0)
@ -75,7 +74,6 @@ const classifyToolExits = (
interrupted: causes.some(Cause.hasInterrupts),
declines,
failure,
infraError: failure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(failure)),
}
}
@ -86,7 +84,7 @@ const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const store = yield* SessionStore.Service
const context = yield* SessionContext.Service
@ -146,7 +144,7 @@ const layer = Layer.effect(
// compaction boundary, so the rebuilt step needs identity inside the new epoch.
let assistantMessageID = SessionMessage.ID.create()
const retry = yield* Schedule.toStepWithSleep(
SessionRunnerRetry.schedule(events, sessionID, () => assistantMessageID),
SessionRunnerRetry.schedule(bus, sessionID, () => assistantMessageID),
)
/**
* Consumes one retry allowance: sleeps the scheduled backoff, or publishes
@ -157,7 +155,7 @@ const layer = Layer.effect(
retry(failure).pipe(
Effect.as(CallOutcome.Retry({ step: failure.step })),
Pull.catchDone(() =>
events
bus
.publish(SessionEvent.Step.Failed, {
sessionID,
assistantMessageID,
@ -203,8 +201,8 @@ const layer = Layer.effect(
const selected = yield* context.select(sessionID)
// Establish what the model knows before admitting what the user said, so
// a blocked first step leaves pending inputs untouched.
yield* InstructionState.prepare(db, events, selected.instructions, selected.session.id)
const promoted = promotable ? yield* SessionPending.promote(db, events, selected.session.id, promotable) : 0
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
const promoted = promotable ? yield* SessionPending.promote(db, bus, selected.session.id, promotable) : 0
// Promoted input opens a fresh step allowance.
const currentStep = promoted > 0 ? 1 : step
const loaded = yield* context.load(selected)
@ -231,7 +229,7 @@ const layer = Layer.effect(
}> = []
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(events, {
const publisher = createLLMEventPublisher(bus, {
sessionID: session.id,
agent: agent.id,
// The selected catalog identity, not model.id: route-level ids are provider API
@ -260,7 +258,7 @@ const layer = Layer.effect(
const publishStepEnd = (finish: NonNullable<StepRecord["finish"]>) =>
Effect.gen(function* () {
const end = yield* captureStepEnd()
yield* events.publish(SessionEvent.Step.Ended, {
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: finish.finish,
@ -310,6 +308,9 @@ const layer = Layer.effect(
// The fiber owns its call: it publishes its own completion, masked so a
// finished execution always reaches its durable settlement.
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
Effect.catchTag("Tool.Error", (error) =>
publisher.failTool(event.id, toSessionError(error)).pipe(Effect.asVoid),
),
),
).pipe(Effect.forkScoped),
})
@ -386,9 +387,8 @@ const layer = Layer.effect(
yield* publisher.failAssistant(STEP_INTERRUPTED)
}
if (tools.failure !== undefined) {
const error = toSessionError(tools.infraError ?? Cause.squash(tools.failure))
const error = toSessionError(Cause.squash(tools.failure))
yield* publisher.failUnsettledTools(error)
if (tools.infraError !== undefined) yield* publisher.failAssistant(error)
}
// Local calls have joined, so the remaining sweeps only close hosted calls the
// provider promised but never resolved.
@ -413,8 +413,7 @@ const layer = Layer.effect(
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
if ((tools.interrupted || tools.infraError !== undefined) && tools.failure)
return yield* Effect.failCause(tools.failure)
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
if (tools.interrupted && joined._tag === "Failure") return yield* Effect.failCause(joined.cause)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
return CallOutcome.Completed({
@ -450,7 +449,7 @@ const layer = Layer.effect(
if (Exit.isSuccess(compacted)) return
const unsettled = yield* SessionPending.compaction(db, sessionID)
if (unsettled)
yield* events.publish(SessionEvent.Compaction.Failed, {
yield* bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
error: Cause.hasInterruptsOnly(compacted.cause)
@ -471,7 +470,7 @@ const layer = Layer.effect(
if (message.type !== "assistant") continue
for (const tool of message.content) {
if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue
yield* events.publish(SessionEvent.Tool.Failed, {
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID,
assistantMessageID: message.id,
callID: tool.id,
@ -503,7 +502,7 @@ export const node = makeLocationNode({
service: Service,
layer,
deps: [
EventV2.node,
Bus.node,
llmClient,
SessionContext.node,
SessionModelRequest.node,

View file

@ -5,8 +5,8 @@ import { Model } from "@opencode-ai/ai"
import { Context, Effect, Layer, Schema } from "effect"
import { Catalog } from "../../catalog"
import { ModelResolver } from "../../model-resolver"
import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
import { Capabilities, ID, Info, Ref, VariantID } from "../../model"
import { Provider } from "../../provider"
import { SessionSchema } from "../schema"
export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelectedError>()(
@ -20,7 +20,7 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelec
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
"SessionRunnerModel.ModelUnavailableError",
{ providerID: ProviderV2.ID, modelID: ModelV2.ID },
{ providerID: Provider.ID, modelID: ID },
) {
override get message() {
return `Model unavailable: ${this.providerID}/${this.modelID}`
@ -38,21 +38,21 @@ export interface Interface {
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunnerModel") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRunnerModel") {}
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
export const resolved = (
model: Model,
options: {
readonly capabilities: ModelV2.Capabilities
readonly variant?: ModelV2.VariantID
readonly cost: ModelV2.Info["cost"]
readonly capabilities: Capabilities
readonly variant?: VariantID
readonly cost: Info["cost"]
},
): Resolved => ({
model,
ref: ModelV2.Ref.make({
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.provider),
ref: Ref.make({
id: ID.make(model.id),
providerID: Provider.ID.make(model.provider),
...(options.variant === undefined ? {} : { variant: options.variant }),
}),
capabilities: options.capabilities,

View file

@ -1,23 +1,22 @@
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Effect } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { Bus } from "../../bus"
import { Model } from "../../model"
import { SessionEvent } from "../event"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money"
import { AgentV2 } from "../../agent"
import { Agent } from "../../agent"
import { Snapshot } from "../../snapshot"
import { RelativePath } from "../../schema"
import { SessionUsage } from "../usage"
import { Tool } from "../../tool/tool"
import type { ToolRegistry } from "../../tool/registry"
import { Tool } from "@opencode-ai/schema/tool"
type Input = {
readonly sessionID: SessionSchema.ID
readonly agent: AgentV2.ID
readonly model: ModelV2.Ref
readonly agent: Agent.ID
readonly model: Model.Ref
readonly providerMetadataKey: string
readonly snapshot?: Snapshot.ID
readonly assistantMessageID: SessionMessage.ID
@ -48,12 +47,26 @@ export interface StepRecord {
}
/** Derives canonical model content from a provider-hosted tool result. */
const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => {
type NonEmptyContent = readonly [Tool.Content, ...Tool.Content[]]
const nonEmpty = (content: ReadonlyArray<Tool.Content>): NonEmptyContent | undefined =>
content.length > 0 ? (content as NonEmptyContent) : undefined
const stringify = (value: unknown) => {
if (typeof value === "string") return value
try {
return JSON.stringify(value) ?? String(value)
} catch {
return String(value)
}
}
const hostedContent = (result: ToolResultValue): NonEmptyContent => {
if (result.type === "content") {
const content = Tool.nonEmpty(result.value)
const content = nonEmpty(result.value)
if (content !== undefined) return content
}
return [{ type: "text", text: Tool.stringify(result.value) }]
return [{ type: "text", text: stringify(result.value) }]
}
/**
@ -67,7 +80,7 @@ const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => {
* order: each publishing fiber is sequential, so per-source order holds by construction,
* and consumers fold by callID/ordinal rather than global position.
*/
export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish">, input: Input) => {
export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, input: Input) => {
const tools = new Map<
string,
{
@ -76,10 +89,10 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
called: boolean
settled: boolean
providerExecuted: boolean
progress?: ToolRegistry.Progress
progress?: Tool.Metadata
}
>()
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) =>
const failureSnapshot = (tool: { readonly progress?: Tool.Metadata }) =>
tool.progress === undefined ? {} : { metadata: tool.progress }
const assistantMessageID = input.assistantMessageID
let stepStarted = false
@ -92,7 +105,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const startAssistant = Effect.fnUntraced(function* () {
if (stepStarted) return assistantMessageID
stepStarted = true
yield* events.publish(SessionEvent.Step.Started, {
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: input.sessionID,
agent: input.agent,
model: input.model,
@ -151,7 +164,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
"text",
(_textID, value, ordinal, state) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Text.Ended, {
yield* bus.publish(SessionEvent.Text.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal,
@ -165,7 +178,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
"reasoning",
(_reasoningID, value, ordinal, state) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Reasoning.Ended, {
yield* bus.publish(SessionEvent.Reasoning.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal,
@ -179,7 +192,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
Effect.gen(function* () {
const tool = tools.get(callID)
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${callID}`))
yield* events.publish(SessionEvent.Tool.Input.Ended, {
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
@ -209,7 +222,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
providerExecuted: event.providerExecuted === true,
})
yield* toolInput.start(event.id)
yield* events.publish(SessionEvent.Tool.Input.Started, {
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID: input.sessionID,
assistantMessageID,
callID: event.id,
@ -242,7 +255,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
if (toolInput.has(event.id)) yield* endToolInput(event, event.raw)
tool.settled = true
yield* events.publish(SessionEvent.Tool.Failed, {
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
@ -263,7 +276,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const tool = tools.get(callID)
if (!tool || tool.settled) return false
tool.settled = true
yield* events.publish(SessionEvent.Tool.Failed, {
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
@ -300,7 +313,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
if (stepFailed || stepFailure === undefined) return
const assistantMessageID = yield* startAssistant()
stepFailed = true
yield* events.publish(SessionEvent.Step.Failed, {
yield* bus.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID,
assistantMessageID,
error: stepFailure,
@ -328,7 +341,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
case "text-start":
outputStarted = true
const startedTextOrdinal = yield* text.start(event.id, providerState(event.providerMetadata))
yield* events.publish(SessionEvent.Text.Started, {
yield* bus.publish(SessionEvent.Text.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
ordinal: startedTextOrdinal,
@ -336,7 +349,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
return
case "text-delta":
const deltaTextOrdinal = yield* text.append(event.id, event.text, providerState(event.providerMetadata))
yield* events.publish(SessionEvent.Text.Delta, {
yield* bus.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal: deltaTextOrdinal,
@ -349,7 +362,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
case "reasoning-start":
outputStarted = true
const startedReasoningOrdinal = yield* reasoning.start(event.id, providerState(event.providerMetadata))
yield* events.publish(SessionEvent.Reasoning.Started, {
yield* bus.publish(SessionEvent.Reasoning.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
ordinal: startedReasoningOrdinal,
@ -362,7 +375,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
event.text,
providerState(event.providerMetadata),
)
yield* events.publish(SessionEvent.Reasoning.Delta, {
yield* bus.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal: deltaReasoningOrdinal,
@ -383,7 +396,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
if (!toolInput.has(event.id)) return yield* Effect.die(new Error(`Tool input delta after end: ${event.id}`))
yield* toolInput.append(event.id, event.text)
yield* events.publish(SessionEvent.Tool.Input.Delta, {
yield* bus.publish(SessionEvent.Tool.Input.Delta, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
@ -408,7 +421,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
tool.called = true
tool.providerExecuted = event.providerExecuted === true
yield* events.publish(SessionEvent.Tool.Called, {
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
@ -434,18 +447,18 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const executed = event.providerExecuted === true || tool.providerExecuted
const resultState = providerState(event.providerMetadata)
if (event.result.type === "error") {
yield* events.publish(SessionEvent.Tool.Failed, {
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
error: { type: "tool.execution", message: Tool.stringify(event.result.value) },
error: { type: "tool.execution", message: stringify(event.result.value) },
...failureSnapshot(tool),
executed,
resultState,
})
return
}
yield* events.publish(SessionEvent.Tool.Success, {
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
@ -462,7 +475,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
return yield* Effect.die(new Error(`Tool error name changed for ${event.id}: ${tool.name} -> ${event.name}`))
if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool error: ${event.id}`))
tool.settled = true
yield* events.publish(SessionEvent.Tool.Failed, {
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
@ -495,12 +508,12 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
}
})
const progress = Effect.fnUntraced(function* (callID: string, update: ToolRegistry.Progress) {
const progress = Effect.fnUntraced(function* (callID: string, update: Tool.Metadata) {
const tool = tools.get(callID)
if (!tool?.called || tool.settled)
return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
tool.progress = update
yield* events.publish(SessionEvent.Tool.Progress, {
yield* bus.publish(SessionEvent.Tool.Progress, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
@ -512,42 +525,27 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const toolExecution = Effect.fnUntraced(function* (
callID: string,
name: string,
execution: ToolRegistry.ToolOutcome,
result: Tool.Result,
) {
const tool = tools.get(callID)
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${callID}`))
if (tool.name !== name)
return yield* Effect.die(new Error(`Tool execution name changed for ${callID}: ${tool.name} -> ${name}`))
if (tool.settled) {
if (execution.status === "error") return
return yield* Effect.die(new Error(`Duplicate tool execution: ${callID}`))
}
if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool execution: ${callID}`))
tool.settled = true
if (execution.status === "completed") {
yield* events.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
content: execution.content,
...(execution.metadata === undefined ? {} : { metadata: execution.metadata }),
executed: tool.providerExecuted,
})
return
}
// An execution-provided snapshot wins; otherwise fall back to retained progress.
const snapshot =
execution.content !== undefined || execution.metadata !== undefined
? {
...(execution.content === undefined ? {} : { content: execution.content }),
...(execution.metadata === undefined ? {} : { metadata: execution.metadata }),
}
: failureSnapshot(tool)
yield* events.publish(SessionEvent.Tool.Failed, {
const content =
typeof result.content === "string"
? [{ type: "text" as const, text: result.content }]
: result.content === undefined
? []
: [...result.content]
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${callID}`))
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
error: execution.error,
...snapshot,
content: [content[0], ...content.slice(1)],
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
executed: tool.providerExecuted,
})
})

View file

@ -3,7 +3,7 @@ export * as SessionRunnerRetry from "./retry"
import { LLMError } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Data, Duration, Effect, Schedule } from "effect"
import { EventV2 } from "../../event"
import { Bus } from "../../bus"
import { SessionEvent } from "../event"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
@ -41,7 +41,7 @@ const retryAfter = (failure: RetryableFailure) => {
return undefined
}
export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID, assistantMessageID: () => SessionMessage.ID) =>
export const schedule = (bus: Bus.Interface, sessionID: SessionSchema.ID, assistantMessageID: () => SessionMessage.ID) =>
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
Schedule.setInputType<RetryableFailure>(),
Schedule.modifyDelay(({ input: failure, duration: delay }) => {
@ -49,7 +49,7 @@ export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID,
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
}),
Schedule.tap((metadata) =>
events.publish(SessionEvent.RetryScheduled, {
bus.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: assistantMessageID(),
attempt: metadata.attempt + 1,

View file

@ -1,6 +1,6 @@
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
import { Option, Schema } from "effect"
import type { ModelV2 } from "../../model"
import type { Model } from "../../model"
import { SessionMessage } from "../message"
import type { FileAttachment } from "@opencode-ai/schema/prompt"
@ -108,7 +108,7 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
}
}
const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, providerMetadataKey: string) => {
const assistant = (message: SessionMessage.Assistant, model: Model.Ref, providerMetadataKey: string) => {
const sameProvider = String(message.model.providerID) === String(model.providerID)
const sameModel = sameProvider && String(message.model.id) === String(model.id)
const reuseProviderMetadata = sameModel && message.error === undefined
@ -177,7 +177,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
]
}
function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, providerMetadataKey: string): Message[] {
function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMetadataKey: string): Message[] {
switch (message.type) {
case "agent-switched":
case "model-switched":
@ -239,9 +239,9 @@ ${message.recent}
}
}
/** Translate projected V2 Session history into canonical @opencode-ai/ai context. */
/** Translate projected Session history into canonical @opencode-ai/ai context. */
export const toLLMMessages = (
messages: readonly SessionMessage.Info[],
model: ModelV2.Ref,
model: Model.Ref,
providerMetadataKey: string = model.providerID,
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey))

View file

@ -6,10 +6,10 @@ import type { SessionMessage } from "./message"
import type { SessionPending } from "./pending"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import { PermissionV1 } from "../v1/permission"
import { ProjectV2 } from "../project"
import { Project } from "../project"
import type { SessionSchema } from "./schema"
import type { MessageID, PartID, SessionV1 } from "../v1/session"
import { WorkspaceV2 } from "../workspace"
import { Workspace } from "../workspace"
import { Timestamps } from "../database/schema.sql"
import type { Instruction } from "@opencode-ai/schema/instruction"
import type { Session } from "@opencode-ai/schema/session"
@ -26,10 +26,10 @@ export const SessionTable = sqliteTable(
{
id: text().$type<SessionSchema.ID>().primaryKey(),
project_id: text()
.$type<ProjectV2.ID>()
.$type<Project.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
workspace_id: text().$type<WorkspaceV2.ID>(),
workspace_id: text().$type<Workspace.ID>(),
parent_id: text().$type<SessionSchema.ID>(),
fork_session_id: text().$type<SessionSchema.ID>(),
fork_message_id: text().$type<SessionMessage.ID>(),

View file

@ -23,7 +23,7 @@ export interface Interface {
readonly suspend: (sessionIDs: Iterable<Session.ID>) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionStore") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionStore") {}
const layer = Layer.effect(
Service,

View file

@ -2,9 +2,9 @@ export * as SessionTitle from "./title"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import { Context, Effect, Layer, Stream } from "effect"
import { AgentV2 } from "../agent"
import { Agent } from "../agent"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { Bus } from "../bus"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app"
import { llmClient } from "../effect/app-node-platform"
@ -19,11 +19,11 @@ const MAX_LENGTH = 100
type Dependencies = {
readonly app: App.Info
readonly events: EventV2.Interface
readonly bus: Bus.Interface
readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
}
readonly agents: AgentV2.Interface
readonly agents: Agent.Interface
readonly models: SessionRunnerModel.Interface
}
@ -32,7 +32,7 @@ export interface Interface {
readonly generateForFirstPrompt: (session: SessionSchema.Info) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionTitle") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTitle") {}
const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)
@ -44,7 +44,7 @@ const make = (dependencies: Dependencies) => {
if (session.parentID) return
const firstUser = yield* SessionHistory.firstUserMessageIfOnly(db, session.id)
if (!firstUser) return
const agent = yield* dependencies.agents.get(AgentV2.ID.make("title"))
const agent = yield* dependencies.agents.get(Agent.ID.make("title"))
if (!agent) return
const resolved = yield* (
agent.model
@ -57,7 +57,7 @@ const make = (dependencies: Dependencies) => {
let usage: SessionUsage.Recorded | undefined
const recordUsage = Effect.suspend(() =>
usage
? dependencies.events.publish(SessionEvent.UsageRecorded, {
? dependencies.bus.publish(SessionEvent.UsageRecorded, {
sessionID: session.id,
source: "title",
...usage,
@ -96,7 +96,7 @@ const make = (dependencies: Dependencies) => {
.map((line) => line.trim())
.find((line) => line.length > 0)
if (!title) return
yield* dependencies.events.publish(SessionEvent.Renamed, {
yield* dependencies.bus.publish(SessionEvent.Renamed, {
sessionID: session.id,
title: truncate(title),
})
@ -107,13 +107,13 @@ const make = (dependencies: Dependencies) => {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const agents = yield* AgentV2.Service
const agents = yield* Agent.Service
const models = yield* SessionRunnerModel.Service
const database = yield* Database.Service
const app = yield* App.Metadata
const title = make({ events, llm, agents, models, app })
const title = make({ bus, llm, agents, models, app })
return Service.of({
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
})
@ -123,5 +123,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node, App.node],
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, Database.node, App.node],
})

View file

@ -1,10 +1,9 @@
import { LLMError, ToolFailure } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
import { Tool } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
import { Permission } from "../permission"
import { Question } from "../question"
import { Integration } from "../integration"
import { ToolOutputStore } from "../tool-output-store"
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error"
import { SessionRunnerModel } from "./runner/model"
@ -37,9 +36,9 @@ export function toSessionError(cause: unknown): SessionError.Error {
}
}
}
if (cause instanceof PermissionV2.BlockedError) return { type: "permission.rejected", message: cause.message }
if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message }
if (cause instanceof ToolFailure || cause instanceof Tool.Failure) {
if (cause instanceof Permission.BlockedError) return { type: "permission.rejected", message: cause.message }
if (cause instanceof Question.RejectedError) return { type: "aborted", message: cause.message }
if (cause instanceof ToolFailure || cause instanceof Tool.Error) {
if (cause.error === undefined) return { type: "tool.execution", message: cause.message }
// The canonical error is the sole model-visible representation, so a cause
// with no message must not erase the tool's curated failure message.
@ -57,6 +56,5 @@ export function toSessionError(cause: unknown): SessionError.Error {
)
return { type: "provider.no-route", message: cause.message }
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
if (cause instanceof ToolOutputStore.StorageError) return { type: "unknown", message: cause.message }
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
}

View file

@ -3,7 +3,7 @@ export * as SessionUsage from "./usage"
import type { Usage } from "@opencode-ai/ai"
import { Money } from "@opencode-ai/schema/money"
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
import type { ModelV2 } from "../model"
import type { Model } from "../model"
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
@ -18,7 +18,7 @@ export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
})
// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract.
export function calculateCost(costs: ModelV2.Info["cost"], usage: TokenUsage.Info) {
export function calculateCost(costs: Model.Info["cost"], usage: TokenUsage.Info) {
const context = usage.input + usage.cache.read + usage.cache.write
const tier = costs
.filter((cost) => cost.tier?.type === "context" && context > cost.tier.size)
@ -36,7 +36,7 @@ export function calculateCost(costs: ModelV2.Info["cost"], usage: TokenUsage.Inf
export type Recorded = { readonly tokens: TokenUsage.Info; readonly cost: Money.USD }
export const record = (usage: Usage | undefined, costs: ModelV2.Info["cost"]): Recorded => {
export const record = (usage: Usage | undefined, costs: Model.Info["cost"]): Recorded => {
const normalized = tokens(usage)
return { tokens: normalized, cost: calculateCost(costs, normalized) }
}