Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 34 additions & 17 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,26 @@ function sdkKey(npm: string): string | undefined {
return undefined
}

function isMistral(model: Provider.Model) {
const id = model.api.id.toLowerCase()
return (
model.providerID === "mistral" ||
["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => id.includes(family))
)
}

function mistralBridge(
model: Provider.Model,
previous: ModelMessage | undefined,
next: ModelMessage | undefined,
): ModelMessage | undefined {
if (!isMistral(model) || previous?.role !== "tool" || next?.role !== "user") return
return {
role: "assistant",
content: [{ type: "text", text: "Done." }],
}
}

// TODO: fix this stupid inefficient dogshit function
function normalizeMessages(
msgs: ModelMessage[],
Expand Down Expand Up @@ -251,11 +271,7 @@ function normalizeMessages(
})
}

const modelID = model.api.id.toLowerCase()
if (
model.providerID === "mistral" ||
["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family))
) {
if (isMistral(model)) {
const scrub = (id: string) => {
return id
.replace(/[^a-zA-Z0-9]/g, "") // Remove non-alphanumeric characters
Expand Down Expand Up @@ -286,17 +302,8 @@ function normalizeMessages(
result.push(msg)

// Fix message sequence: tool messages cannot be followed by user messages
if (msg.role === "tool" && nextMsg?.role === "user") {
result.push({
role: "assistant",
content: [
{
type: "text",
text: "Done.",
},
],
})
}
const bridge = mistralBridge(model, msg, nextMsg)
if (bridge) result.push(bridge)
}
return result
}
Expand Down Expand Up @@ -463,9 +470,18 @@ function mapProviderOptions(
})
}

export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) {
export type MessageSuffix = readonly ModelMessage[]

export function message(
msgs: ModelMessage[],
model: Provider.Model,
options: Record<string, unknown>,
suffix?: MessageSuffix,
) {
msgs = unsupportedParts(msgs, model)
msgs = normalizeMessages(msgs, model, options)
const tail = suffix?.length ? normalizeMessages(unsupportedParts([...suffix], model), model, options) : []
const bridge = mistralBridge(model, msgs.at(-1), tail[0])
const usesAnthropicAutomaticCaching =
options.cacheControl !== undefined &&
(model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/google-vertex/anthropic")
Expand All @@ -483,6 +499,7 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re
) {
msgs = applyCaching(msgs, model)
}
if (bridge || tail.length) msgs = [...msgs, ...(bridge ? [bridge] : []), ...tail]

// Remap providerOptions keys from stored providerID to expected SDK key
const key = sdkKey(model.api.npm)
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type StreamInput = {
permission?: PermissionV1.Ruleset
system: string[]
messages: ModelMessage[]
messageSuffix?: readonly ModelMessage[]
small?: boolean
tools: Record<string, Tool>
retries?: number
Expand Down Expand Up @@ -230,6 +231,7 @@ const live: Layer.Layer<
auth: info,
llmClient,
messages: prepared.messages,
messageSuffix: prepared.messageSuffix,
tools: prepared.tools,
toolChoice: input.toolChoice,
temperature: prepared.params.temperature,
Expand Down Expand Up @@ -334,6 +336,7 @@ const live: Layer.Layer<
args.params.prompt,
input.model,
prepared.messageTransformOptions,
prepared.messageSuffix,
)
}
return args.params
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/session/llm/native-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type StreamInput = {
readonly auth: Auth.Info | undefined
readonly llmClient: LLMClientShape
readonly messages: ModelMessage[]
readonly messageSuffix?: readonly ModelMessage[]
readonly tools: Record<string, Tool>
readonly toolChoice?: "auto" | "required" | "none"
readonly temperature?: number
Expand Down Expand Up @@ -91,7 +92,7 @@ export function stream(input: StreamInput): StreamResult {
model: input.model,
apiKey: current.apiKey,
baseURL: current.baseURL,
messages: ProviderTransform.message(input.messages, input.model, input.providerOptions ?? {}),
messages: ProviderTransform.message(input.messages, input.model, input.providerOptions ?? {}, input.messageSuffix),
toolChoice: input.toolChoice,
temperature: input.temperature,
topP: input.topP,
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/session/llm/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type PrepareInput = {
readonly permission?: PermissionV1.Ruleset
readonly system: string[]
readonly messages: ModelMessage[]
readonly messageSuffix?: readonly ModelMessage[]
readonly small?: boolean
readonly tools: Record<string, Tool>
readonly provider: Provider.Info
Expand All @@ -38,6 +39,7 @@ type PrepareInput = {
export type Prepared = {
readonly system: string[]
readonly messages: ModelMessage[]
readonly messageSuffix?: readonly ModelMessage[]
readonly tools: Record<string, Tool>
readonly params: {
readonly temperature?: number
Expand Down Expand Up @@ -181,6 +183,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
return {
system,
messages,
messageSuffix: input.messageSuffix,
tools: Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b))),
params,
messageTransformOptions: options,
Expand Down
117 changes: 89 additions & 28 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,40 @@ function providerMeta(metadata: Record<string, any> | undefined) {
return Object.keys(rest).length > 0 ? rest : undefined
}

export const toModelMessagesEffect = Effect.fnUntraced(function* (
export function appendedTailCount(before: readonly string[], after: readonly WithParts[]): number {
const anchor = before.at(-1)
if (!anchor) return 0
if (new Set(before).size !== before.length) return 0

const ids = after.map((message) => message.info.id)
if (new Set(ids).size !== ids.length) return 0
const anchorIndexes = ids.flatMap((id, index) => (id === anchor ? [index] : []))
if (anchorIndexes.length !== 1) return 0

const anchorIndex = anchorIndexes[0]!
const rank = new Map(before.map((id, index) => [id, index]))
const surviving = ids.slice(0, anchorIndex + 1).flatMap((id) => (rank.has(id) ? [rank.get(id)!] : []))
if (surviving.some((index, offset) => offset > 0 && index <= surviving[offset - 1]!)) return 0

const prior = new Set(before)
const suffix = ids.slice(anchorIndex + 1)
if (suffix.some((id) => prior.has(id))) return 0
return suffix.length
}

export const toModelMessagesSplitEffect = Effect.fnUntraced(function* (
input: WithParts[],
model: Provider.Model,
options?: { stripMedia?: boolean; toolOutputMaxChars?: number },
options?: { stripMedia?: boolean; toolOutputMaxChars?: number; requestOnlyTailCount?: number },
) {
const result: UIMessage[] = []
const toolNames = new Set<string>()
const requestOnlyStart = input.length - Math.min(Math.max(options?.requestOnlyTailCount ?? 0, 0), input.length)
const requestOnly = new WeakSet<UIMessage>()
const emit = (message: UIMessage, sourceIndex: number) => {
result.push(message)
if (sourceIndex >= requestOnlyStart) requestOnly.add(message)
}
// Track media from tool results that need to be injected as user messages
// for providers that don't support that media type in tool results.
//
Expand Down Expand Up @@ -192,7 +219,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
return { type: "json", value: output as never }
}

for (const msg of input) {
for (const [sourceIndex, msg] of input.entries()) {
if (msg.parts.length === 0) continue

if (msg.info.role === "user") {
Expand Down Expand Up @@ -238,7 +265,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
})
}
}
if (userMessage.parts.length > 0) result.push(userMessage)
if (userMessage.parts.length > 0) emit(userMessage, sourceIndex)
}

if (msg.info.role === "assistant") {
Expand Down Expand Up @@ -376,42 +403,76 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
}
}
if (assistantMessage.parts.length > 0) {
result.push(assistantMessage)
emit(assistantMessage, sourceIndex)
// 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,
filename: attachment.filename,
})),
],
})
emit(
{
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,
filename: attachment.filename,
})),
],
},
sourceIndex,
)
}
}
}
}

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")),
{
const convert = (messages: UIMessage[]) =>
Effect.promise(() =>
convertToModelMessages(messages, {
//@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput)
tools,
},
),
)
}),
)

const filtered = result.filter((msg) => msg.parts.some((part) => part.type !== "step-start"))
const split = filtered.findIndex((message) => requestOnly.has(message))
if (split < 0 || !filtered.slice(split).every((message) => requestOnly.has(message))) {
return { messages: yield* convert(filtered), tail: [] }
}

// The AI SDK lowers ModelMessage into a provider prompt before middleware runs. The suffix joins
// inside that middleware, so only text (plus the step boundary that converts into text messages)
// is already in the same shape on both sides of that lowering. Keep richer plugin appends in the
// main array rather than risk changing files, tool results, or other structured content in flight.
const tail = filtered.slice(split)
if (
!tail.every((message) =>
message.parts.every((part) => part.type === "step-start" || (part.type === "text" && part.text !== "")),
)
) {
return { messages: yield* convert(filtered), tail: [] }
}

return {
messages: yield* convert(filtered.slice(0, split)),
tail: yield* convert(tail),
}
})

export const toModelMessagesEffect = Effect.fnUntraced(function* (
input: WithParts[],
model: Provider.Model,
options?: { stripMedia?: boolean; toolOutputMaxChars?: number },
) {
const result = yield* toModelMessagesSplitEffect(input, model, options)
return result.messages
})

export function toModelMessages(
Expand Down
18 changes: 14 additions & 4 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1252,14 +1252,16 @@ const layer = Layer.effect(
if (step === 1)
yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope))

const beforeTransform = msgs.map((message) => message.info.id)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const requestOnlyTailCount = MessageV2.appendedTailCount(beforeTransform, msgs)

const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([
sys.skills(agent),
sys.environment(model),
instruction.system().pipe(Effect.orDie),
sys.mcp(agent, session.permission),
MessageV2.toModelMessagesEffect(msgs, model),
MessageV2.toModelMessagesSplitEffect(msgs, model, { requestOnlyTailCount }),
])
const system = [
...env,
Expand All @@ -1276,9 +1278,17 @@ const layer = Layer.effect(
sessionID,
parentSessionID: session.parentID,
system,
messages: [
...modelMsgs,
...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : []),
messages: modelMsgs.messages,
messageSuffix: [
...modelMsgs.tail,
...(isLastStep
? [
{
role: "assistant" as const,
content: [{ type: "text" as const, text: MAX_STEPS_PROMPT }],
},
]
: []),
],
tools,
model,
Expand Down
Loading
Loading