import { BusEvent } from "@/bus/bus-event" import { SessionID, MessageID, PartID } from "./schema" import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import { LSP } from "../lsp" import { Snapshot } from "@/snapshot" import { SyncEvent } from "../sync" import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage" import { MessageTable, PartTable, SessionTable } from "./session.sql" import { ProviderError } from "@/provider" import { iife } from "@/util/iife" import { errorMessage } from "@/util/error" import { isMedia } from "@/util/media" import type { SystemError } from "bun" import type { Provider } from "@/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Schema, Types } from "effect" import { zod, ZodOverride } from "@/util/effect-zod" import { NonNegativeInt, withStatics } from "@/util/schema" import { namedSchemaError } from "@/util/named-schema-error" import { EffectLogger } from "@/effect" /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { code: "ZlibError" errno: number path: string } export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:" export { isMedia } export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String }) export const StructuredOutputError = namedSchemaError("StructuredOutputError", { message: Schema.String, retries: Schema.Number, }) export const AuthError = namedSchemaError("ProviderAuthError", { providerID: Schema.String, message: Schema.String, }) export const APIError = namedSchemaError("APIError", { message: Schema.String, statusCode: Schema.optional(Schema.Number), isRetryable: Schema.Boolean, responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), responseBody: Schema.optional(Schema.String), metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), }) export type APIError = z.infer export const ContextOverflowError = namedSchemaError("ContextOverflowError", { message: Schema.String, responseBody: Schema.optional(Schema.String), }) export class OutputFormatText extends Schema.Class("OutputFormatText")({ type: Schema.Literal("text"), }) { static readonly zod = zod(this) } export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ type: Schema.Literal("json_schema"), schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), }) { static readonly zod = zod(this) } const _Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ discriminator: "type", identifier: "OutputFormat", }) export const Format = Object.assign(_Format, { zod: zod(_Format) }) export type OutputFormat = Schema.Schema.Type const partBase = { id: PartID, sessionID: SessionID, messageID: MessageID, } export const SnapshotPart = Schema.Struct({ ...partBase, type: Schema.Literal("snapshot"), snapshot: Schema.String, }) .annotate({ identifier: "SnapshotPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type SnapshotPart = Types.DeepMutable> export const PatchPart = Schema.Struct({ ...partBase, type: Schema.Literal("patch"), hash: Schema.String, files: Schema.Array(Schema.String), }) .annotate({ identifier: "PatchPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type PatchPart = Types.DeepMutable> export const TextPart = Schema.Struct({ ...partBase, type: Schema.Literal("text"), text: Schema.String, synthetic: Schema.optional(Schema.Boolean), ignored: Schema.optional(Schema.Boolean), time: Schema.optional( Schema.Struct({ start: Schema.Number, end: Schema.optional(Schema.Number), }), ), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), }) .annotate({ identifier: "TextPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type TextPart = Types.DeepMutable> export const ReasoningPart = Schema.Struct({ ...partBase, type: Schema.Literal("reasoning"), text: Schema.String, metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), time: Schema.Struct({ start: Schema.Number, end: Schema.optional(Schema.Number), }), }) .annotate({ identifier: "ReasoningPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type ReasoningPart = Types.DeepMutable> const filePartSourceBase = { text: Schema.Struct({ value: Schema.String, start: Schema.Int, end: Schema.Int, }).annotate({ identifier: "FilePartSourceText" }), } export const FileSource = Schema.Struct({ ...filePartSourceBase, type: Schema.Literal("file"), path: Schema.String, }) .annotate({ identifier: "FileSource" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export const SymbolSource = Schema.Struct({ ...filePartSourceBase, type: Schema.Literal("symbol"), path: Schema.String, range: LSP.Range, name: Schema.String, kind: Schema.Int, }) .annotate({ identifier: "SymbolSource" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export const ResourceSource = Schema.Struct({ ...filePartSourceBase, type: Schema.Literal("resource"), clientName: Schema.String, uri: Schema.String, }) .annotate({ identifier: "ResourceSource" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) const _FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({ discriminator: "type", identifier: "FilePartSource", }) export const FilePartSource = Object.assign(_FilePartSource, { zod: zod(_FilePartSource) }) export const FilePart = Schema.Struct({ ...partBase, type: Schema.Literal("file"), mime: Schema.String, filename: Schema.optional(Schema.String), url: Schema.String, source: Schema.optional(_FilePartSource), }) .annotate({ identifier: "FilePart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type FilePart = Types.DeepMutable> export const AgentPart = Schema.Struct({ ...partBase, type: Schema.Literal("agent"), name: Schema.String, source: Schema.optional( Schema.Struct({ value: Schema.String, start: Schema.Int, end: Schema.Int, }), ), }) .annotate({ identifier: "AgentPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type AgentPart = Types.DeepMutable> export const CompactionPart = Schema.Struct({ ...partBase, type: Schema.Literal("compaction"), auto: Schema.Boolean, overflow: Schema.optional(Schema.Boolean), tail_start_id: Schema.optional(MessageID), }) .annotate({ identifier: "CompactionPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type CompactionPart = Types.DeepMutable> export const SubtaskPart = Schema.Struct({ ...partBase, type: Schema.Literal("subtask"), prompt: Schema.String, description: Schema.String, agent: Schema.String, model: Schema.optional( Schema.Struct({ providerID: ProviderID, modelID: ModelID, }), ), command: Schema.optional(Schema.String), }) .annotate({ identifier: "SubtaskPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type SubtaskPart = Types.DeepMutable> export const RetryPart = Schema.Struct({ ...partBase, type: Schema.Literal("retry"), attempt: Schema.Number, // APIError is still NamedError-based Zod; bridge via ZodOverride until errors migrate. error: Schema.Any.annotate({ [ZodOverride]: APIError.Schema }), time: Schema.Struct({ created: Schema.Number, }), }) .annotate({ identifier: "RetryPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type RetryPart = Omit>, "error"> & { error: APIError } export const StepStartPart = Schema.Struct({ ...partBase, type: Schema.Literal("step-start"), snapshot: Schema.optional(Schema.String), }) .annotate({ identifier: "StepStartPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type StepStartPart = Types.DeepMutable> export const StepFinishPart = Schema.Struct({ ...partBase, type: Schema.Literal("step-finish"), reason: Schema.String, snapshot: Schema.optional(Schema.String), cost: Schema.Number, tokens: Schema.Struct({ total: Schema.optional(Schema.Number), input: Schema.Number, output: Schema.Number, reasoning: Schema.Number, cache: Schema.Struct({ read: Schema.Number, write: Schema.Number, }), }), }) .annotate({ identifier: "StepFinishPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type StepFinishPart = Types.DeepMutable> export const ToolStatePending = Schema.Struct({ status: Schema.Literal("pending"), input: Schema.Record(Schema.String, Schema.Any), raw: Schema.String, }) .annotate({ identifier: "ToolStatePending" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type ToolStatePending = Types.DeepMutable> export const ToolStateRunning = Schema.Struct({ status: Schema.Literal("running"), input: Schema.Record(Schema.String, Schema.Any), title: Schema.optional(Schema.String), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), time: Schema.Struct({ start: Schema.Number, }), }) .annotate({ identifier: "ToolStateRunning" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type ToolStateRunning = Types.DeepMutable> export const ToolStateCompleted = Schema.Struct({ status: Schema.Literal("completed"), input: Schema.Record(Schema.String, Schema.Any), output: Schema.String, title: Schema.String, metadata: Schema.Record(Schema.String, Schema.Any), time: Schema.Struct({ start: Schema.Number, end: Schema.Number, compacted: Schema.optional(Schema.Number), }), attachments: Schema.optional(Schema.Array(FilePart)), }) .annotate({ identifier: "ToolStateCompleted" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type ToolStateCompleted = Types.DeepMutable> function truncateToolOutput(text: string, maxChars?: number) { if (!maxChars || text.length <= maxChars) return text const omitted = text.length - maxChars return `${text.slice(0, maxChars)}\n[Tool output truncated for compaction: omitted ${omitted} chars]` } export const ToolStateError = Schema.Struct({ status: Schema.Literal("error"), input: Schema.Record(Schema.String, Schema.Any), error: Schema.String, metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), time: Schema.Struct({ start: Schema.Number, end: Schema.Number, }), }) .annotate({ identifier: "ToolStateError" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type ToolStateError = Types.DeepMutable> const _ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).annotate({ discriminator: "status", identifier: "ToolState", }) // Cast the derived zod so downstream z.infer sees the same mutable shape that // our exported TS types expose (the pre-migration Zod inferences were mutable). export const ToolState = Object.assign(_ToolState, { zod: zod(_ToolState) as unknown as z.ZodType< ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError >, }) export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError export const ToolPart = Schema.Struct({ ...partBase, type: Schema.Literal("tool"), callID: Schema.String, tool: Schema.String, state: _ToolState, metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), }) .annotate({ identifier: "ToolPart" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type ToolPart = Omit>, "state"> & { state: ToolState } const messageBase = { id: MessageID, sessionID: SessionID, } export const User = Schema.Struct({ ...messageBase, role: Schema.Literal("user"), time: Schema.Struct({ created: Schema.Number, }), format: Schema.optional(_Format), summary: Schema.optional( Schema.Struct({ title: Schema.optional(Schema.String), body: Schema.optional(Schema.String), diffs: Schema.Array(Snapshot.FileDiff), }), ), agent: Schema.String, model: Schema.Struct({ providerID: ProviderID, modelID: ModelID, variant: Schema.optional(Schema.String), }), system: Schema.optional(Schema.String), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), }) .annotate({ identifier: "UserMessage" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type User = Types.DeepMutable> const _Part = Schema.Union([ TextPart, SubtaskPart, ReasoningPart, FilePart, ToolPart, StepStartPart, StepFinishPart, SnapshotPart, PatchPart, AgentPart, RetryPart, CompactionPart, ]).annotate({ discriminator: "type", identifier: "Part" }) export const Part = Object.assign(_Part, { zod: zod(_Part) as unknown as z.ZodType< | TextPart | SubtaskPart | ReasoningPart | FilePart | ToolPart | StepStartPart | StepFinishPart | SnapshotPart | PatchPart | AgentPart | RetryPart | CompactionPart >, }) export type Part = | TextPart | SubtaskPart | ReasoningPart | FilePart | ToolPart | StepStartPart | StepFinishPart | SnapshotPart | PatchPart | AgentPart | RetryPart | CompactionPart // Errors are still NamedError-based Zod; bridge via ZodOverride so the derived // Zod + JSON Schema emit the original discriminatedUnion shape. Migrating the // error classes to Schema.TaggedErrorClass is a separate slice. const AssistantErrorZod = z.discriminatedUnion("name", [ AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema, AbortedError.Schema, StructuredOutputError.Schema, ContextOverflowError.Schema, APIError.Schema, ]) type AssistantError = z.infer // ── Prompt input schemas ───────────────────────────────────────────────────── // // Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the // ambient IDs (`messageID`, `sessionID`) that live on stored parts, and may // omit `id` to let the server allocate one. These Schema-Struct variants // carry that shape, and `SessionPrompt.PromptInput` just references the // derived `.zod` (no omit/partial gymnastics needed at the call site). export const TextPartInput = Schema.Struct({ id: Schema.optional(PartID), type: Schema.Literal("text"), text: Schema.String, synthetic: Schema.optional(Schema.Boolean), ignored: Schema.optional(Schema.Boolean), time: Schema.optional( Schema.Struct({ start: Schema.Number, end: Schema.optional(Schema.Number), }), ), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), }) .annotate({ identifier: "TextPartInput" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type TextPartInput = Types.DeepMutable> export const FilePartInput = Schema.Struct({ id: Schema.optional(PartID), type: Schema.Literal("file"), mime: Schema.String, filename: Schema.optional(Schema.String), url: Schema.String, source: Schema.optional(_FilePartSource), }) .annotate({ identifier: "FilePartInput" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type FilePartInput = Types.DeepMutable> export const AgentPartInput = Schema.Struct({ id: Schema.optional(PartID), type: Schema.Literal("agent"), name: Schema.String, source: Schema.optional( Schema.Struct({ value: Schema.String, start: Schema.Int, end: Schema.Int, }), ), }) .annotate({ identifier: "AgentPartInput" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type AgentPartInput = Types.DeepMutable> export const SubtaskPartInput = Schema.Struct({ id: Schema.optional(PartID), type: Schema.Literal("subtask"), prompt: Schema.String, description: Schema.String, agent: Schema.String, model: Schema.optional( Schema.Struct({ providerID: ProviderID, modelID: ModelID, }), ), command: Schema.optional(Schema.String), }) .annotate({ identifier: "SubtaskPartInput" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type SubtaskPartInput = Types.DeepMutable> export const Assistant = Schema.Struct({ ...messageBase, role: Schema.Literal("assistant"), time: Schema.Struct({ created: Schema.Number, completed: Schema.optional(Schema.Number), }), error: Schema.optional(Schema.Any.annotate({ [ZodOverride]: AssistantErrorZod })), parentID: MessageID, modelID: ModelID, providerID: ProviderID, /** * @deprecated */ mode: Schema.String, agent: Schema.String, path: Schema.Struct({ cwd: Schema.String, root: Schema.String, }), summary: Schema.optional(Schema.Boolean), cost: Schema.Number, tokens: Schema.Struct({ total: Schema.optional(Schema.Number), input: Schema.Number, output: Schema.Number, reasoning: Schema.Number, cache: Schema.Struct({ read: Schema.Number, write: Schema.Number, }), }), structured: Schema.optional(Schema.Any), variant: Schema.optional(Schema.String), finish: Schema.optional(Schema.String), }) .annotate({ identifier: "AssistantMessage" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type Assistant = Omit>, "error"> & { error?: AssistantError } const _Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) export const Info = Object.assign(_Info, { zod: zod(_Info) as unknown as z.ZodType, }) export type Info = User | Assistant const UpdatedEventSchema = Schema.Struct({ sessionID: SessionID, info: _Info, }) const RemovedEventSchema = Schema.Struct({ sessionID: SessionID, messageID: MessageID, }) const PartUpdatedEventSchema = Schema.Struct({ sessionID: SessionID, part: _Part, time: Schema.Number, }) const PartRemovedEventSchema = Schema.Struct({ sessionID: SessionID, messageID: MessageID, partID: PartID, }) export const Event = { Updated: SyncEvent.define({ type: "message.updated", version: 1, aggregate: "sessionID", schema: UpdatedEventSchema, }), Removed: SyncEvent.define({ type: "message.removed", version: 1, aggregate: "sessionID", schema: RemovedEventSchema, }), PartUpdated: SyncEvent.define({ type: "message.part.updated", version: 1, aggregate: "sessionID", schema: PartUpdatedEventSchema, }), PartDelta: BusEvent.define( "message.part.delta", Schema.Struct({ sessionID: SessionID, messageID: MessageID, partID: PartID, field: Schema.String, delta: Schema.String, }), ), PartRemoved: SyncEvent.define({ type: "message.part.removed", version: 1, aggregate: "sessionID", schema: PartRemovedEventSchema, }), } export const WithParts = Schema.Struct({ info: _Info, parts: Schema.Array(_Part), }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type WithParts = { info: Info parts: Part[] } const Cursor = Schema.Struct({ id: MessageID, time: Schema.Number, }) type Cursor = typeof Cursor.Type const decodeCursor = Schema.decodeUnknownSync(Cursor) export const cursor = { encode(input: Cursor) { return Buffer.from(JSON.stringify(input)).toString("base64url") }, decode(input: string) { return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) }, } const info = (row: typeof MessageTable.$inferSelect) => ({ ...row.data, id: row.id, sessionID: row.session_id, }) as Info const part = (row: typeof PartTable.$inferSelect) => ({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id, }) as Part const older = (row: Cursor) => or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id))) function hydrate(rows: (typeof MessageTable.$inferSelect)[]) { const ids = rows.map((row) => row.id) const partByMessage = new Map() if (ids.length > 0) { const partRows = Database.use((db) => db .select() .from(PartTable) .where(inArray(PartTable.message_id, ids)) .orderBy(PartTable.message_id, PartTable.id) .all(), ) for (const row of partRows) { const next = part(row) const list = partByMessage.get(row.message_id) if (list) list.push(next) else partByMessage.set(row.message_id, [next]) } } return rows.map((row) => ({ info: info(row), parts: partByMessage.get(row.id) ?? [], })) } function providerMeta(metadata: Record | undefined) { if (!metadata) return undefined const { providerExecuted: _, ...rest } = metadata return Object.keys(rest).length > 0 ? rest : undefined } export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: WithParts[], model: Provider.Model, options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, ) { const result: UIMessage[] = [] const toolNames = new Set() // Track media from tool results that need to be injected as user messages // for providers that don't support media in tool results. // // OpenAI-compatible APIs only support string content in tool results, so we need // to extract media and inject as user messages. Other SDKs (anthropic, google, // bedrock) handle type: "content" with media parts natively. // // Only apply this workaround if the model actually supports image input - // otherwise there's no point extracting images. const supportsMediaInToolResults = (() => { if (model.api.npm === "@ai-sdk/anthropic") return true if (model.api.npm === "@ai-sdk/openai") return true if (model.api.npm === "@ai-sdk/amazon-bedrock") return true if (model.api.npm === "@ai-sdk/google-vertex/anthropic") return true if (model.api.npm === "@ai-sdk/google") { const id = model.api.id.toLowerCase() return id.includes("gemini-3") && !id.includes("gemini-2") } return false })() const toModelOutput = (options: { toolCallId: string; input: unknown; output: unknown }) => { const output = options.output if (typeof output === "string") { return { type: "text", value: output } } if (typeof output === "object") { const outputObject = output as { text: string attachments?: Array<{ mime: string; url: string }> } const attachments = (outputObject.attachments ?? []).filter((attachment) => { return attachment.url.startsWith("data:") && attachment.url.includes(",") }) return { type: "content", value: [ { type: "text", text: outputObject.text }, ...attachments.map((attachment) => ({ type: "media", mediaType: attachment.mime, data: iife(() => { const commaIndex = attachment.url.indexOf(",") return commaIndex === -1 ? attachment.url : attachment.url.slice(commaIndex + 1) }), })), ], } } return { type: "json", value: output as never } } for (const msg of input) { if (msg.parts.length === 0) continue if (msg.info.role === "user") { const userMessage: UIMessage = { id: msg.info.id, role: "user", parts: [], } result.push(userMessage) for (const part of msg.parts) { if (part.type === "text" && !part.ignored) userMessage.parts.push({ type: "text", text: part.text, }) // text/plain and directory files are converted into text parts, ignore them if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") { if (options?.stripMedia && isMedia(part.mime)) { userMessage.parts.push({ type: "text", text: `[Attached ${part.mime}: ${part.filename ?? "file"}]`, }) } else { userMessage.parts.push({ type: "file", url: part.url, mediaType: part.mime, filename: part.filename, }) } } if (part.type === "compaction") { userMessage.parts.push({ type: "text", text: "What did we do so far?", }) } if (part.type === "subtask") { userMessage.parts.push({ type: "text", text: "The following tool was executed by the user", }) } } } if (msg.info.role === "assistant") { const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` const media: Array<{ mime: string; url: string }> = [] if ( msg.info.error && !( AbortedError.isInstance(msg.info.error) && msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") ) ) { continue } const assistantMessage: UIMessage = { id: msg.info.id, role: "assistant", parts: [], } for (const part of msg.parts) { if (part.type === "text") assistantMessage.parts.push({ type: "text", text: part.text, ...(differentModel ? {} : { providerMetadata: part.metadata }), }) if (part.type === "step-start") assistantMessage.parts.push({ type: "step-start", }) if (part.type === "tool") { toolNames.add(part.tool) if (part.state.status === "completed") { const outputText = part.state.time.compacted ? "[Old tool result content cleared]" : truncateToolOutput(part.state.output, options?.toolOutputMaxChars) const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? []) // For providers that don't support media in tool results, extract media files // (images, PDFs) to be sent as a separate user message const mediaAttachments = attachments.filter((a) => isMedia(a.mime)) const nonMediaAttachments = attachments.filter((a) => !isMedia(a.mime)) if (!supportsMediaInToolResults && mediaAttachments.length > 0) { media.push(...mediaAttachments) } const finalAttachments = supportsMediaInToolResults ? attachments : nonMediaAttachments const output = finalAttachments.length > 0 ? { text: outputText, attachments: finalAttachments, } : outputText assistantMessage.parts.push({ type: ("tool-" + part.tool) as `tool-${string}`, state: "output-available", toolCallId: part.callID, input: part.state.input, output, ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), }) } if (part.state.status === "error") { const output = part.state.metadata?.interrupted === true ? part.state.metadata.output : undefined if (typeof output === "string") { assistantMessage.parts.push({ type: ("tool-" + part.tool) as `tool-${string}`, state: "output-available", toolCallId: part.callID, input: part.state.input, output, ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), }) } else { assistantMessage.parts.push({ type: ("tool-" + part.tool) as `tool-${string}`, state: "output-error", toolCallId: part.callID, input: part.state.input, errorText: part.state.error, ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), }) } } // Handle pending/running tool calls to prevent dangling tool_use blocks // Anthropic/Claude APIs require every tool_use to have a corresponding tool_result if (part.state.status === "pending" || part.state.status === "running") assistantMessage.parts.push({ type: ("tool-" + part.tool) as `tool-${string}`, state: "output-error", toolCallId: part.callID, input: part.state.input, errorText: "[Tool execution was interrupted]", ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), }) } if (part.type === "reasoning") { assistantMessage.parts.push({ type: "reasoning", text: part.text, ...(differentModel ? {} : { providerMetadata: part.metadata }), }) } } if (assistantMessage.parts.length > 0) { result.push(assistantMessage) // Inject pending media as a user message for providers that don't support // media (images, PDFs) in tool results if (media.length > 0) { result.push({ id: MessageID.ascending(), role: "user", parts: [ { type: "text" as const, text: SYNTHETIC_ATTACHMENT_PROMPT, }, ...media.map((attachment) => ({ type: "file" as const, url: attachment.url, mediaType: attachment.mime, })), ], }) } } } } const tools = Object.fromEntries(Array.from(toolNames).map((toolName) => [toolName, { toModelOutput }])) return yield* Effect.promise(() => convertToModelMessages( result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")), { //@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput) tools, }, ), ) }) export function toModelMessages( input: WithParts[], model: Provider.Model, options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, ): Promise { return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer))) } export function page(input: { sessionID: SessionID; limit: number; before?: string }) { const before = input.before ? cursor.decode(input.before) : undefined const where = before ? and(eq(MessageTable.session_id, input.sessionID), older(before)) : eq(MessageTable.session_id, input.sessionID) const rows = Database.use((db) => db .select() .from(MessageTable) .where(where) .orderBy(desc(MessageTable.time_created), desc(MessageTable.id)) .limit(input.limit + 1) .all(), ) if (rows.length === 0) { const row = Database.use((db) => db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, input.sessionID)).get(), ) if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` }) return { items: [] as WithParts[], more: false, } } const more = rows.length > input.limit const slice = more ? rows.slice(0, input.limit) : rows const items = hydrate(slice) items.reverse() const tail = slice.at(-1) return { items, more, cursor: more && tail ? cursor.encode({ id: tail.id, time: tail.time_created }) : undefined, } } export function* stream(sessionID: SessionID) { const size = 50 let before: string | undefined while (true) { const next = page({ sessionID, limit: size, before }) if (next.items.length === 0) break for (let i = next.items.length - 1; i >= 0; i--) { yield next.items[i] } if (!next.more || !next.cursor) break before = next.cursor } } export function parts(message_id: MessageID) { const rows = Database.use((db) => db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(), ) return rows.map( (row) => ({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id, }) as Part, ) } export function get(input: { sessionID: SessionID; messageID: MessageID }): WithParts { const row = Database.use((db) => db .select() .from(MessageTable) .where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID))) .get(), ) if (!row) throw new NotFoundError({ message: `Message not found: ${input.messageID}` }) return { info: info(row), parts: parts(input.messageID), } } export function filterCompacted(msgs: Iterable) { const result = [] as WithParts[] const completed = new Set() let retain: MessageID | undefined for (const msg of msgs) { result.push(msg) if (retain) { if (msg.info.id === retain) break continue } if (msg.info.role === "user" && completed.has(msg.info.id)) { const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction") if (!part) continue if (!part.tail_start_id) break retain = part.tail_start_id if (msg.info.id === retain) break continue } if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction")) break if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error) completed.add(msg.info.parentID) } result.reverse() return result } export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: SessionID) { return filterCompacted(stream(sessionID)) }) export function fromError( e: unknown, ctx: { providerID: ProviderID; aborted?: boolean }, ): NonNullable { switch (true) { case e instanceof DOMException && e.name === "AbortError": return new AbortedError( { message: e.message }, { cause: e, }, ).toObject() case OutputLengthError.isInstance(e): return e case LoadAPIKeyError.isInstance(e): return new AuthError( { providerID: ctx.providerID, message: e.message, }, { cause: e }, ).toObject() case (e as SystemError)?.code === "ECONNRESET": return new APIError( { message: "Connection reset by server", isRetryable: true, metadata: { code: (e as SystemError).code ?? "", syscall: (e as SystemError).syscall ?? "", message: (e as SystemError).message ?? "", }, }, { cause: e }, ).toObject() case e instanceof Error && (e as FetchDecompressionError).code === "ZlibError": if (ctx.aborted) { return new AbortedError({ message: e.message }, { cause: e }).toObject() } return new APIError( { message: "Response decompression failed", isRetryable: true, metadata: { code: (e as FetchDecompressionError).code, message: e.message, }, }, { cause: e }, ).toObject() case APICallError.isInstance(e): const parsed = ProviderError.parseAPICallError({ providerID: ctx.providerID, error: e, }) if (parsed.type === "context_overflow") { return new ContextOverflowError( { message: parsed.message, responseBody: parsed.responseBody, }, { cause: e }, ).toObject() } return new APIError( { message: parsed.message, statusCode: parsed.statusCode, isRetryable: parsed.isRetryable, responseHeaders: parsed.responseHeaders, responseBody: parsed.responseBody, metadata: parsed.metadata, }, { cause: e }, ).toObject() case e instanceof Error: return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject() default: try { const parsed = ProviderError.parseStreamError(e) if (parsed) { if (parsed.type === "context_overflow") { return new ContextOverflowError( { message: parsed.message, responseBody: parsed.responseBody, }, { cause: e }, ).toObject() } return new APIError( { message: parsed.message, isRetryable: parsed.isRetryable, responseBody: parsed.responseBody, }, { cause: e, }, ).toObject() } } catch {} return new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }).toObject() } } export * as MessageV2 from "./message-v2"