1197 lines
38 KiB
TypeScript
1197 lines
38 KiB
TypeScript
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<typeof APIError.Schema>
|
|
export const ContextOverflowError = namedSchemaError("ContextOverflowError", {
|
|
message: Schema.String,
|
|
responseBody: Schema.optional(Schema.String),
|
|
})
|
|
|
|
export class OutputFormatText extends Schema.Class<OutputFormatText>("OutputFormatText")({
|
|
type: Schema.Literal("text"),
|
|
}) {
|
|
static readonly zod = zod(this)
|
|
}
|
|
|
|
export class OutputFormatJsonSchema extends Schema.Class<OutputFormatJsonSchema>("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<typeof _Format>
|
|
|
|
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<Schema.Schema.Type<typeof SnapshotPart>>
|
|
|
|
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<Schema.Schema.Type<typeof PatchPart>>
|
|
|
|
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<Schema.Schema.Type<typeof TextPart>>
|
|
|
|
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<Schema.Schema.Type<typeof ReasoningPart>>
|
|
|
|
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<Schema.Schema.Type<typeof FilePart>>
|
|
|
|
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<Schema.Schema.Type<typeof AgentPart>>
|
|
|
|
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<Schema.Schema.Type<typeof CompactionPart>>
|
|
|
|
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<Schema.Schema.Type<typeof SubtaskPart>>
|
|
|
|
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<Types.DeepMutable<Schema.Schema.Type<typeof RetryPart>>, "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<Schema.Schema.Type<typeof StepStartPart>>
|
|
|
|
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<Schema.Schema.Type<typeof StepFinishPart>>
|
|
|
|
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<Schema.Schema.Type<typeof ToolStatePending>>
|
|
|
|
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<Schema.Schema.Type<typeof ToolStateRunning>>
|
|
|
|
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<Schema.Schema.Type<typeof ToolStateCompleted>>
|
|
|
|
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<Schema.Schema.Type<typeof ToolStateError>>
|
|
|
|
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<Types.DeepMutable<Schema.Schema.Type<typeof ToolPart>>, "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<Schema.Schema.Type<typeof User>>
|
|
|
|
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<typeof AssistantErrorZod>
|
|
|
|
// ── 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<Schema.Schema.Type<typeof TextPartInput>>
|
|
|
|
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<Schema.Schema.Type<typeof FilePartInput>>
|
|
|
|
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<Schema.Schema.Type<typeof AgentPartInput>>
|
|
|
|
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<Schema.Schema.Type<typeof SubtaskPartInput>>
|
|
|
|
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<Types.DeepMutable<Schema.Schema.Type<typeof Assistant>>, "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<User | Assistant>,
|
|
})
|
|
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<string, Part[]>()
|
|
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<string, any> | 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<string>()
|
|
// 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<ModelMessage[]> {
|
|
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<WithParts>) {
|
|
const result = [] as WithParts[]
|
|
const completed = new Set<string>()
|
|
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<Assistant["error"]> {
|
|
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"
|