mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 12:10:45 +03:00
refactor(ai): narrow message content by role (#61196)
This commit is contained in:
committed by
GitHub
Unverified
parent
8b038f672b
commit
e387bde030
@@ -184,7 +184,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
|
||||
Effect.succeed({
|
||||
model: request.model.id,
|
||||
input: request.messages
|
||||
.flatMap((message) => message.content)
|
||||
.flatMap((message) => [...message.content])
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n"),
|
||||
|
||||
@@ -12,10 +12,13 @@
|
||||
// count against the four-breakpoint budget; auto only fills remaining slots.
|
||||
import { CacheHint, type CachePolicy, type CachePolicyObject, type CacheTTL } from "./schema/options.js"
|
||||
import {
|
||||
AssistantMessage,
|
||||
type LLMRequest,
|
||||
Message,
|
||||
type Message,
|
||||
SystemMessage,
|
||||
ToolDefinition,
|
||||
ToolMessage,
|
||||
UserMessage,
|
||||
type ContentPart,
|
||||
type ToolEntry,
|
||||
} from "./schema/messages.js"
|
||||
@@ -148,18 +151,7 @@ const markMessageAt = (
|
||||
const existing = target.content[markAt]
|
||||
if (!existing || !isCacheablePart(existing) || existing.cache || budget.remaining === 0) return messages
|
||||
budget.remaining -= 1
|
||||
const next =
|
||||
target.role === "system"
|
||||
? new SystemMessage({
|
||||
...target,
|
||||
content: target.content.map((part, i) =>
|
||||
i === markAt && part.type === "text" ? { ...part, cache: hint } : part,
|
||||
),
|
||||
})
|
||||
: Message.make({
|
||||
...target,
|
||||
content: target.content.map((part, i) => (i === markAt ? { ...existing, cache: hint } : part)),
|
||||
})
|
||||
const next = cachePartAt(target, markAt, hint)
|
||||
// Single pass over `messages`, substituting the one updated entry. Long
|
||||
// conversations call this on every request, so avoid `.map()` here — its
|
||||
// closure dispatch and identity copies show up in profiling.
|
||||
@@ -168,6 +160,22 @@ const markMessageAt = (
|
||||
return result
|
||||
}
|
||||
|
||||
const cachePartAt = (message: Message, index: number, hint: CacheHint): Message => {
|
||||
switch (message.role) {
|
||||
case "system":
|
||||
return new SystemMessage({ ...message, content: cacheContentAt(message.content, index, hint) })
|
||||
case "user":
|
||||
return new UserMessage({ ...message, content: cacheContentAt(message.content, index, hint) })
|
||||
case "assistant":
|
||||
return new AssistantMessage({ ...message, content: cacheContentAt(message.content, index, hint) })
|
||||
case "tool":
|
||||
return new ToolMessage({ ...message, content: cacheContentAt(message.content, index, hint) })
|
||||
}
|
||||
}
|
||||
|
||||
const cacheContentAt = <Part extends ContentPart>(content: ReadonlyArray<Part>, index: number, hint: CacheHint) =>
|
||||
content.map((part, i) => (i === index ? { ...part, cache: hint } : part))
|
||||
|
||||
const markMessages = (
|
||||
messages: ReadonlyArray<Message>,
|
||||
strategy: NonNullable<CachePolicyObject["messages"]>,
|
||||
|
||||
@@ -44,7 +44,7 @@ export type LLMRequestInput<Model extends LanguageModel = LanguageModel> = TextR
|
||||
|
||||
/** Exactly one of `prompt`, a single user message, or `messages`, the full conversation. */
|
||||
export type PromptOrMessages =
|
||||
| { readonly prompt: Message.ContentInput; readonly messages?: never }
|
||||
| { readonly prompt: Message.UserContentInput; readonly messages?: never }
|
||||
| { readonly messages: ReadonlyArray<Message | Message.Input>; readonly prompt?: never }
|
||||
|
||||
interface TextRequestFields<Model extends LanguageModel>
|
||||
|
||||
@@ -838,7 +838,6 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, breakpoi
|
||||
content.push(yield* lowerMedia(part, request, breakpoints))
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
|
||||
}
|
||||
if (content.length > 0) messages.push({ role: "user", content })
|
||||
continue
|
||||
@@ -896,8 +895,6 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, breakpoi
|
||||
|
||||
const content: AnthropicToolResultBlock[] = []
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "tool", ["tool-result"])
|
||||
content.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: scrubToolCallID(part.id),
|
||||
|
||||
@@ -384,8 +384,6 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, breakpoi
|
||||
if (message.role === "user") {
|
||||
const content: BedrockUserBlock[] = []
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["text", "media"]))
|
||||
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "user", ["text", "media"])
|
||||
if (part.type === "text") {
|
||||
content.push(...textWithCache(breakpoints, part.text, part.cache))
|
||||
continue
|
||||
@@ -437,8 +435,6 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, breakpoi
|
||||
|
||||
const content: BedrockUserBlock[] = []
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"])
|
||||
const result = yield* lowerToolResult(part, documentNames, normalizeID)
|
||||
const images: BedrockMedia.ImageBlock[] = hoistImages
|
||||
? result.toolResult.content.filter((item) => "image" in item)
|
||||
|
||||
@@ -340,8 +340,6 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
|
||||
if (message.role === "user") {
|
||||
const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["text", "media"]))
|
||||
return yield* ProviderShared.unsupportedContent("Gemini", "user", ["text", "media"])
|
||||
parts.push(yield* lowerContentPart(part))
|
||||
}
|
||||
contents.push({ role: "user", parts })
|
||||
@@ -395,8 +393,6 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
|
||||
|
||||
const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent("Gemini", "tool", ["tool-result"])
|
||||
if (part.result.type !== "content") {
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Base64 } from "effect/encoding"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { LLMEvent, type LLMRequest, Message, ToolResultPart } from "../schema/index.js"
|
||||
import { AssistantMessage, LLMEvent, type LLMRequest, ToolResultPart } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
@@ -65,9 +65,9 @@ const fromRequest = Effect.fn("MetaResponses.fromRequest")(function* (request: L
|
||||
{
|
||||
...request,
|
||||
messages: request.messages.map((message) =>
|
||||
message.role === "system"
|
||||
message.role !== "assistant"
|
||||
? message
|
||||
: Message.make({
|
||||
: new AssistantMessage({
|
||||
...message,
|
||||
content: message.content.map((part) => {
|
||||
if (
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
type MediaPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolMessage,
|
||||
type UserMessage,
|
||||
} from "../schema/index.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
@@ -227,7 +229,7 @@ const lowerMedia = Effect.fnUntraced(function* (part: MediaPart) {
|
||||
return yield* ProviderShared.invalidRequest(`Mistral Chat does not support media type ${part.media.mediaType}`)
|
||||
})
|
||||
|
||||
const lowerUser = Effect.fnUntraced(function* (message: LLMRequest["messages"][number]) {
|
||||
const lowerUser = Effect.fnUntraced(function* (message: UserMessage) {
|
||||
const content: MistralUserContent[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
@@ -238,7 +240,6 @@ const lowerUser = Effect.fnUntraced(function* (message: LLMRequest["messages"][n
|
||||
content.push(yield* lowerMedia(part))
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent("Mistral Chat", "user", ["text", "media"])
|
||||
}
|
||||
if (content.every((part) => part.type === "text"))
|
||||
return { role: "user" as const, content: content.map((part) => part.text).join("") }
|
||||
@@ -285,14 +286,9 @@ const lowerAssistant = Effect.fnUntraced(function* (
|
||||
}
|
||||
})
|
||||
|
||||
const lowerToolResults = Effect.fnUntraced(function* (
|
||||
message: LLMRequest["messages"][number],
|
||||
normalizeID: (id: string) => string,
|
||||
) {
|
||||
const lowerToolResults = Effect.fnUntraced(function* (message: ToolMessage, normalizeID: (id: string) => string) {
|
||||
const output: MistralMessage[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type !== "tool-result")
|
||||
return yield* ProviderShared.unsupportedContent("Mistral Chat", "tool", ["tool-result"])
|
||||
if (part.result.type !== "content") {
|
||||
output.push({
|
||||
role: "tool",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolResultPart,
|
||||
type UserMessage,
|
||||
} from "../schema/index.js"
|
||||
import type { Media } from "../media.js"
|
||||
import {
|
||||
@@ -606,13 +607,12 @@ const lowerMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMReq
|
||||
})
|
||||
|
||||
const lowerUserContent = Effect.fnUntraced(function* (
|
||||
part: LLMRequest["messages"][number]["content"][number],
|
||||
part: UserMessage["content"][number],
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
if (part.type === "text") return { type: "input_text" as const, text: part.text }
|
||||
if (part.type === "media") return yield* lowerMedia(part, request, adapter)
|
||||
return yield* ProviderShared.unsupportedContent(adapter.name, "user", ["text", "media"])
|
||||
return yield* lowerMedia(part, request, adapter)
|
||||
})
|
||||
|
||||
// Tool results may carry structured text, images, and files. Keep media as provider-native
|
||||
@@ -794,8 +794,6 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, adapter:
|
||||
}
|
||||
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent(adapter.name, "tool", ["tool-result"])
|
||||
input.push({
|
||||
type: customTool(part) ? "custom_tool_call_output" : "function_call_output",
|
||||
call_id: part.id,
|
||||
|
||||
@@ -509,7 +509,6 @@ const lowerUserMessage = Effect.fnUntraced(function* (message: UserMessage, opti
|
||||
content.push(yield* lowerMedia(part))
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"])
|
||||
}
|
||||
if (content.every((part) => part.type === "text" && part.cache_control === undefined))
|
||||
return {
|
||||
@@ -580,8 +579,6 @@ const lowerToolMessages = Effect.fnUntraced(function* (message: ToolMessage, opt
|
||||
const messages: OpenAIChatMessage[] = []
|
||||
const attachments: OpenAIChatUserContent[] = []
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"])
|
||||
if (part.result.type !== "content") {
|
||||
messages.push(
|
||||
toolMessage(
|
||||
|
||||
@@ -7,15 +7,18 @@ import {
|
||||
InvalidRequestError,
|
||||
UnsupportedOperationError,
|
||||
AIError,
|
||||
AssistantMessage,
|
||||
type LLMRequest,
|
||||
Message,
|
||||
ToolDefinition,
|
||||
ToolMessage,
|
||||
type ContentPart,
|
||||
type MediaPart,
|
||||
type OpenString,
|
||||
type ProviderID,
|
||||
type RequestEncoding,
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolEntry,
|
||||
type ToolResultPart,
|
||||
} from "../schema/index.js"
|
||||
@@ -348,14 +351,15 @@ export const flattenTools = (tools: ReadonlyArray<ToolEntry>, path: ReadonlyArra
|
||||
|
||||
export const flattenToolRequest = (request: LLMRequest) => {
|
||||
const messages = request.messages.map((message) => {
|
||||
if (message.role === "system") return message
|
||||
const content = message.content.map((part) => {
|
||||
if ((part.type !== "tool-call" && part.type !== "tool-result") || part.namespace === undefined) return part
|
||||
return { ...part, name: `${part.namespace}_${part.name}`, namespace: undefined }
|
||||
})
|
||||
return content.every((part, index) => part === message.content[index])
|
||||
? message
|
||||
: Message.make({ ...message, content })
|
||||
if (message.role === "assistant") {
|
||||
const content = message.content.map((part) =>
|
||||
part.type === "tool-call" || part.type === "tool-result" ? flattenToolName(part) : part,
|
||||
)
|
||||
return sameParts(content, message.content) ? message : new AssistantMessage({ ...message, content })
|
||||
}
|
||||
if (message.role !== "tool") return message
|
||||
const content = message.content.map(flattenToolName)
|
||||
return sameParts(content, message.content) ? message : new ToolMessage({ ...message, content })
|
||||
})
|
||||
return {
|
||||
tools: flattenTools(request.tools),
|
||||
@@ -365,6 +369,12 @@ export const flattenToolRequest = (request: LLMRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
const flattenToolName = <Part extends ToolCallPart | ToolResultPart>(part: Part): Part =>
|
||||
part.namespace === undefined ? part : { ...part, name: `${part.namespace}_${part.name}`, namespace: undefined }
|
||||
|
||||
const sameParts = (next: ReadonlyArray<ContentPart>, previous: ReadonlyArray<ContentPart>) =>
|
||||
next.every((part, index) => part === previous[index])
|
||||
|
||||
export const matchToolChoice = <Auto, None, Required, Tool>(
|
||||
route: string,
|
||||
toolChoice: NonNullable<LLMRequest["toolChoice"]>,
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
CompactionResponse,
|
||||
type LLMRequest,
|
||||
Message,
|
||||
type ContentPart,
|
||||
type MediaPart,
|
||||
type TextPart,
|
||||
mergeJsonRecords,
|
||||
} from "../../schema/index.js"
|
||||
import type { CompactOperation } from "../../route/client.js"
|
||||
@@ -174,7 +175,7 @@ function toMessage(item: (typeof Response.Type.output)[number], model: LLMReques
|
||||
return Message.make({
|
||||
role: item.role,
|
||||
providerMetadata: { [key]: { itemId: item.id, type: item.type, status: item.status, phase: item.phase } },
|
||||
content: item.content.map((part): ContentPart => {
|
||||
content: item.content.map((part): TextPart | MediaPart => {
|
||||
if (part.type === "input_text" || part.type === "output_text") return { type: "text", text: part.text }
|
||||
if (part.type === "input_image")
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ToolOutput,
|
||||
ToolResultPart,
|
||||
ToolResultValue,
|
||||
type ContentPart,
|
||||
} from "./messages.js"
|
||||
import { ProviderMetadata } from "./options.js"
|
||||
import { Media } from "../media.js"
|
||||
@@ -486,18 +485,20 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
|
||||
}
|
||||
|
||||
type ContentKind = "text" | "reasoning"
|
||||
type AssistantPart = AssistantMessage["content"][number]
|
||||
|
||||
const contentPart = (type: ContentKind, text: string, providerMetadata: ProviderMetadata | undefined): ContentPart =>
|
||||
const contentPart = (type: ContentKind, text: string, providerMetadata: ProviderMetadata | undefined): AssistantPart =>
|
||||
providerMetadata === undefined ? { type, text } : { type, text, providerMetadata }
|
||||
|
||||
const contentWith = (state: ResponseState, content: ReadonlyArray<ContentPart>): ResponseState => ({
|
||||
const contentWith = (state: ResponseState, content: ReadonlyArray<AssistantPart>): ResponseState => ({
|
||||
...state,
|
||||
message: Message.assistant(content),
|
||||
})
|
||||
|
||||
const appendContent = (state: ResponseState, part: ContentPart) => contentWith(state, [...state.message.content, part])
|
||||
const appendContent = (state: ResponseState, part: AssistantPart) =>
|
||||
contentWith(state, [...state.message.content, part])
|
||||
|
||||
const replaceContent = (state: ResponseState, index: number, part: ContentPart) =>
|
||||
const replaceContent = (state: ResponseState, index: number, part: AssistantPart) =>
|
||||
contentWith(
|
||||
state,
|
||||
state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)),
|
||||
@@ -600,7 +601,7 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
|
||||
}
|
||||
}
|
||||
|
||||
const toolCallContent = (event: ToolCall): ContentPart =>
|
||||
const toolCallContent = (event: ToolCall): AssistantPart =>
|
||||
ToolCallPart.make({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
@@ -610,7 +611,7 @@ const toolCallContent = (event: ToolCall): ContentPart =>
|
||||
...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }),
|
||||
})
|
||||
|
||||
const toolResultContent = (event: ToolResult): ContentPart =>
|
||||
const toolResultContent = (event: ToolResult): AssistantPart =>
|
||||
ToolResultPart.make({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
|
||||
@@ -246,21 +246,25 @@ export class SystemMessage extends Schema.Class<SystemMessage>("LLM.SystemMessag
|
||||
export class UserMessage extends Schema.Class<UserMessage>("LLM.UserMessage")({
|
||||
id: Schema.optional(Schema.String),
|
||||
role: Schema.Literal("user"),
|
||||
content: Schema.Array(ContentPart),
|
||||
content: Schema.Array(Schema.Union([TextPart, MediaPart]).pipe(Schema.toTaggedUnion("type"))),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {}
|
||||
|
||||
export class AssistantMessage extends Schema.Class<AssistantMessage>("LLM.AssistantMessage")({
|
||||
id: Schema.optional(Schema.String),
|
||||
role: Schema.Literal("assistant"),
|
||||
content: Schema.Array(ContentPart),
|
||||
content: Schema.Array(
|
||||
Schema.Union([TextPart, ReasoningPart, ToolCallPart, ToolResultPart, MediaPart, CompactionPart]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
),
|
||||
),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {}
|
||||
|
||||
export class ToolMessage extends Schema.Class<ToolMessage>("LLM.ToolMessage")({
|
||||
id: Schema.optional(Schema.String),
|
||||
role: Schema.Literal("tool"),
|
||||
content: Schema.Array(ContentPart),
|
||||
content: Schema.Array(ToolResultPart),
|
||||
}) {}
|
||||
|
||||
export type Message = SystemMessage | UserMessage | AssistantMessage | ToolMessage
|
||||
@@ -268,16 +272,26 @@ export type Message = SystemMessage | UserMessage | AssistantMessage | ToolMessa
|
||||
export type ContentPartInput = Exclude<ContentPart, MediaPart> | MediaPartInput
|
||||
|
||||
export namespace Message {
|
||||
export type ContentInput = string | ContentPartInput | ReadonlyArray<ContentPartInput>
|
||||
export type SystemContentInput = string | TextPart | ReadonlyArray<TextPart>
|
||||
export type ContentInput<Part extends { readonly type: string } = ContentPartInput> =
|
||||
| string
|
||||
| Part
|
||||
| ReadonlyArray<Part>
|
||||
export type SystemContentInput = ContentInput<TextPart>
|
||||
export type UserContentInput = ContentInput<TextPart | MediaPartInput>
|
||||
export type AssistantContentInput = ContentInput<
|
||||
Exclude<AssistantMessage["content"][number], MediaPart> | MediaPartInput
|
||||
>
|
||||
type RoleInput<Props, Content> = Omit<Props, "content"> & { readonly content: Content }
|
||||
export type SystemInput = RoleInput<
|
||||
ConstructorParameters<typeof SystemMessage>[0],
|
||||
string | SystemMessage["content"][number] | SystemMessage["content"]
|
||||
ContentInput<SystemMessage["content"][number]>
|
||||
>
|
||||
export type UserInput = RoleInput<ConstructorParameters<typeof UserMessage>[0], UserContentInput>
|
||||
export type AssistantInput = RoleInput<ConstructorParameters<typeof AssistantMessage>[0], AssistantContentInput>
|
||||
export type ToolInput = RoleInput<
|
||||
ConstructorParameters<typeof ToolMessage>[0],
|
||||
ToolResultPart | ReadonlyArray<ToolResultPart>
|
||||
>
|
||||
export type UserInput = RoleInput<ConstructorParameters<typeof UserMessage>[0], ContentInput>
|
||||
export type AssistantInput = RoleInput<ConstructorParameters<typeof AssistantMessage>[0], ContentInput>
|
||||
export type ToolInput = RoleInput<ConstructorParameters<typeof ToolMessage>[0], ContentInput>
|
||||
export type Input = SystemInput | UserInput | AssistantInput | ToolInput
|
||||
}
|
||||
|
||||
@@ -296,8 +310,12 @@ const content = <Part extends { readonly type: string }>(
|
||||
input: string | Part | ReadonlyArray<Part>,
|
||||
): Array<TextPart | Part> => (typeof input === "string" ? [text(input)] : isParts(input) ? [...input] : [input])
|
||||
|
||||
const contentWithMedia = (input: Message.ContentInput): ContentPart[] =>
|
||||
content(input).map((part) => (part.type === "media" ? mediaPart(part) : part))
|
||||
function contentWithMedia(input: Message.UserContentInput): UserMessage["content"]
|
||||
function contentWithMedia(input: Message.AssistantContentInput): AssistantMessage["content"]
|
||||
function contentWithMedia(input: Message.ContentInput): ReadonlyArray<ContentPart>
|
||||
function contentWithMedia(input: Message.ContentInput): ReadonlyArray<ContentPart> {
|
||||
return content(input).map((part) => (part.type === "media" ? mediaPart(part) : part))
|
||||
}
|
||||
|
||||
function make(input: SystemMessage | Message.SystemInput): SystemMessage
|
||||
function make(input: UserMessage | Message.UserInput): UserMessage
|
||||
@@ -319,7 +337,7 @@ function make(input: Message | Message.Input): Message {
|
||||
case "tool":
|
||||
return input instanceof ToolMessage
|
||||
? input
|
||||
: new ToolMessage({ ...input, content: contentWithMedia(input.content) })
|
||||
: new ToolMessage({ ...input, content: isParts(input.content) ? [...input.content] : [input.content] })
|
||||
}
|
||||
// Untyped callers can still pass an unknown role; reject it the way the schema does.
|
||||
return Schema.decodeUnknownSync(messageSchema)(input)
|
||||
@@ -332,8 +350,8 @@ export const Message = withStatics(messageSchema, {
|
||||
mediaPart({ type: "media", media, ...options }),
|
||||
content: contentWithMedia,
|
||||
make,
|
||||
user: (input: Message.ContentInput) => make({ role: "user", content: input }),
|
||||
assistant: (input: Message.ContentInput) => make({ role: "assistant", content: input }),
|
||||
user: (input: Message.UserContentInput) => make({ role: "user", content: input }),
|
||||
assistant: (input: Message.AssistantContentInput) => make({ role: "assistant", content: input }),
|
||||
/**
|
||||
* Add an operator-authored instruction at this chronological point in the
|
||||
* conversation. This is distinct from the initial `LLMRequest.system`
|
||||
|
||||
@@ -54,7 +54,7 @@ const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
|
||||
Effect.succeed({
|
||||
body: [
|
||||
...request.messages
|
||||
.flatMap((message) => message.content)
|
||||
.flatMap((message) => [...message.content])
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text),
|
||||
...request.tools.map((tool) => `tool:${tool.name}:${tool.description}`),
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {
|
||||
ai,
|
||||
type ContentPart,
|
||||
type AssistantMessage,
|
||||
type LanguageModel,
|
||||
Media,
|
||||
Message,
|
||||
ToolCallPart,
|
||||
ToolDefinition,
|
||||
ToolResultPart,
|
||||
type UserMessage,
|
||||
} from "../src/effect.js"
|
||||
|
||||
export const basicContinuation = ["system", "user-text", "assistant-text", "user-follow-up"] as const
|
||||
@@ -55,8 +56,8 @@ export function continuationRequest(input: {
|
||||
}) {
|
||||
const features = new Set(input.features)
|
||||
const messages = []
|
||||
const firstUser: ContentPart[] = []
|
||||
const firstAssistant: ContentPart[] = []
|
||||
const firstUser: Array<UserMessage["content"][number]> = []
|
||||
const firstAssistant: Array<AssistantMessage["content"][number]> = []
|
||||
|
||||
if (features.has("user-text")) firstUser.push({ type: "text", text: "What is shown here?" })
|
||||
if (features.has("user-image"))
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
LLMEvent,
|
||||
type LLMRequest,
|
||||
Message,
|
||||
type ContentPart,
|
||||
type AssistantMessage,
|
||||
type ProviderMetadata,
|
||||
type ToolCallPart,
|
||||
ToolResultPart,
|
||||
@@ -82,7 +82,7 @@ const indexStep = (event: LLMEvent, index: number): LLMEvent => {
|
||||
}
|
||||
|
||||
const stepState = (events: ReadonlyArray<LLMEvent>) => {
|
||||
const assistantContent: ContentPart[] = []
|
||||
const assistantContent: Array<AssistantMessage["content"][number]> = []
|
||||
const toolCalls: ToolCallPart[] = []
|
||||
let reason: Extract<LLMEvent, { type: "finish" }>["reason"] = { normalized: "unknown" }
|
||||
let usage: Usage | undefined
|
||||
@@ -116,7 +116,7 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
|
||||
}
|
||||
|
||||
const appendText = (
|
||||
content: ContentPart[],
|
||||
content: Array<AssistantMessage["content"][number]>,
|
||||
type: "text" | "reasoning",
|
||||
text: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import {
|
||||
ai,
|
||||
type ContentPart,
|
||||
type AssistantMessage,
|
||||
type FinishReason,
|
||||
type LanguageModel,
|
||||
type LLM,
|
||||
@@ -154,7 +154,7 @@ const encryptedReasoningOptions = {
|
||||
|
||||
type AssistantTextExpectation = string | RegExp
|
||||
|
||||
type UserStep = { readonly type: "user"; readonly content: Message.ContentInput }
|
||||
type UserStep = { readonly type: "user"; readonly content: Message.UserContentInput }
|
||||
type AssistantStep = {
|
||||
readonly type: "assistant"
|
||||
readonly text?: AssistantTextExpectation
|
||||
@@ -171,7 +171,7 @@ type AssistantStep = {
|
||||
}
|
||||
type ConversationStep = UserStep | AssistantStep
|
||||
|
||||
const user = (content: Message.ContentInput): ConversationStep => ({ type: "user", content })
|
||||
const user = (content: Message.UserContentInput): ConversationStep => ({ type: "user", content })
|
||||
|
||||
const assistant = {
|
||||
expectText: (
|
||||
@@ -213,7 +213,7 @@ const assertAssistantToolCall = (response: LLMResponse, expected: NonNullable<As
|
||||
// encrypted reasoning + text, text, or tool call. Keep mixed interleavings in
|
||||
// focused protocol tests where event order can be asserted directly.
|
||||
const assistantMessageFromResponse = (response: LLMResponse, step: AssistantStep) => {
|
||||
const content: ContentPart[] = []
|
||||
const content: Array<AssistantMessage["content"][number]> = []
|
||||
if (step.reasoning === "openai-encrypted") {
|
||||
const reasoning = response.events.find(
|
||||
(event): event is Extract<LLMEvent, { readonly type: "reasoning-end" }> =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as ClaudeSessionHooks from "./session.js"
|
||||
|
||||
import { AssistantMessage, ToolMessage, type ToolResultPart } from "@opencode/ai/effect"
|
||||
import type { Context } from "@opencode/plugin/effect/plugin"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { RequestKind } from "./language-model/request-kind.js"
|
||||
@@ -8,6 +9,16 @@ import { ClaudeProvider } from "./provider.js"
|
||||
// OpenCode records stopped tools (including a plain permission rejection) with this structured error.
|
||||
const isAborted = Schema.is(Schema.Struct({ error: Schema.Struct({ type: Schema.Literal("aborted") }) }))
|
||||
|
||||
const flagToolError = (part: ToolResultPart): ToolResultPart => {
|
||||
if (part.result.type !== "error") return part
|
||||
const flags = {
|
||||
...part.providerMetadata?.[ClaudeProvider.ID],
|
||||
toolError: true,
|
||||
toolAborted: isAborted(part.result.value),
|
||||
}
|
||||
return { ...part, providerMetadata: { ...part.providerMetadata, [ClaudeProvider.ID]: flags } }
|
||||
}
|
||||
|
||||
export const register = Effect.fn("ClaudeSessionHooks.register")(function* (ctx: Context) {
|
||||
yield* ctx.session.hook(
|
||||
"model.request",
|
||||
@@ -29,17 +40,11 @@ export const register = Effect.fn("ClaudeSessionHooks.register")(function* (ctx:
|
||||
delete event.options.maxTokens
|
||||
// Preserve tool error status before the AI SDK bridge flattens error results to plain text.
|
||||
event.messages = event.messages.map((message) => {
|
||||
if (message.role === "system") return message
|
||||
const content = message.content.map((part) => {
|
||||
if (part.type !== "tool-result" || part.result.type !== "error") return part
|
||||
const flags = {
|
||||
...part.providerMetadata?.[ClaudeProvider.ID],
|
||||
toolError: true,
|
||||
toolAborted: isAborted(part.result.value),
|
||||
}
|
||||
return { ...part, providerMetadata: { ...part.providerMetadata, [ClaudeProvider.ID]: flags } }
|
||||
})
|
||||
return { ...message, content }
|
||||
if (message.role === "tool")
|
||||
return new ToolMessage({ ...message, content: message.content.map(flagToolError) })
|
||||
if (message.role !== "assistant") return message
|
||||
const content = message.content.map((part) => (part.type === "tool-result" ? flagToolError(part) : part))
|
||||
return new AssistantMessage({ ...message, content })
|
||||
})
|
||||
}),
|
||||
{ providerID: ClaudeProvider.ID },
|
||||
|
||||
@@ -2,14 +2,20 @@ export * as SessionModelRequest from "./model-request.js"
|
||||
|
||||
import {
|
||||
ai,
|
||||
AssistantMessage,
|
||||
type ContentPart,
|
||||
GenerationOptions,
|
||||
type GenerationOptionsFields,
|
||||
LanguageModel,
|
||||
type LLM,
|
||||
type LLMRequest,
|
||||
type Media,
|
||||
type MediaPart,
|
||||
Message,
|
||||
SystemPart,
|
||||
ToolMessage,
|
||||
type ToolResultPart,
|
||||
UserMessage,
|
||||
} from "@opencode/ai/effect"
|
||||
import type { StreamOptions } from "@opencode/ai/route"
|
||||
import type {
|
||||
@@ -161,29 +167,46 @@ const mediaBytes = (media: Media.Asset) => {
|
||||
const replaceMedia = (
|
||||
messages: LLMRequest["messages"],
|
||||
replace: (media: { mime: string; name: string | undefined; bytes: () => number }) => string | undefined,
|
||||
) =>
|
||||
messages.map((message) => {
|
||||
if (message.role === "system") return message
|
||||
const content = message.content.map((part) => {
|
||||
if (part.type === "media") {
|
||||
const text = replace({ mime: part.media.mediaType, name: part.filename, bytes: () => mediaBytes(part.media) })
|
||||
return text === undefined ? part : Message.text(text)
|
||||
}
|
||||
if (part.type !== "tool-result" || part.result.type !== "content") return part
|
||||
const result = part.result
|
||||
const value = result.value.map((item): Content => {
|
||||
if (item.type !== "file") return item
|
||||
const text = replace({ mime: item.mime, name: item.name, bytes: () => item.uri.length })
|
||||
return text === undefined ? item : { type: "text", text }
|
||||
})
|
||||
return value.every((item, index) => item === result.value[index])
|
||||
? part
|
||||
: { ...part, result: { ...result, value } }
|
||||
) => {
|
||||
const media = (part: MediaPart) => {
|
||||
const text = replace({ mime: part.media.mediaType, name: part.filename, bytes: () => mediaBytes(part.media) })
|
||||
return text === undefined ? part : Message.text(text)
|
||||
}
|
||||
const toolResult = (part: ToolResultPart) => {
|
||||
if (part.result.type !== "content") return part
|
||||
const result = part.result
|
||||
const value = result.value.map((item): Content => {
|
||||
if (item.type !== "file") return item
|
||||
const text = replace({ mime: item.mime, name: item.name, bytes: () => item.uri.length })
|
||||
return text === undefined ? item : { type: "text", text }
|
||||
})
|
||||
return content.every((part, index) => part === message.content[index])
|
||||
? message
|
||||
: Message.make({ ...message, content })
|
||||
return value.every((item, index) => item === result.value[index]) ? part : { ...part, result: { ...result, value } }
|
||||
}
|
||||
return messages.map((message) => {
|
||||
switch (message.role) {
|
||||
case "system":
|
||||
return message
|
||||
case "user": {
|
||||
const content = message.content.map((part) => (part.type === "media" ? media(part) : part))
|
||||
return sameParts(content, message.content) ? message : new UserMessage({ ...message, content })
|
||||
}
|
||||
case "assistant": {
|
||||
const content = message.content.map((part) => {
|
||||
if (part.type === "media") return media(part)
|
||||
return part.type === "tool-result" ? toolResult(part) : part
|
||||
})
|
||||
return sameParts(content, message.content) ? message : new AssistantMessage({ ...message, content })
|
||||
}
|
||||
case "tool": {
|
||||
const content = message.content.map(toolResult)
|
||||
return sameParts(content, message.content) ? message : new ToolMessage({ ...message, content })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const sameParts = (next: ReadonlyArray<ContentPart>, previous: ReadonlyArray<ContentPart>) =>
|
||||
next.every((part, index) => part === previous[index])
|
||||
|
||||
export const unsupportedParts = (
|
||||
messages: LLMRequest["messages"],
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
ReasoningEfforts,
|
||||
ToolCallPart,
|
||||
ToolResultPart,
|
||||
type ContentPart,
|
||||
type AssistantMessage,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type TextPart,
|
||||
} from "@opencode/ai/effect"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import { Option, Schema } from "effect"
|
||||
@@ -17,7 +19,7 @@ import type { FileAttachment } from "@opencode/schema/prompt"
|
||||
|
||||
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
||||
|
||||
const media = (file: FileAttachment): ContentPart => ({
|
||||
const media = (file: FileAttachment): MediaPart => ({
|
||||
type: "media",
|
||||
media: Media.base64(file.data, file.mime),
|
||||
filename: file.name,
|
||||
@@ -34,7 +36,7 @@ const attachmentLocation = (file: FileAttachment) => {
|
||||
}
|
||||
}
|
||||
|
||||
const textAttachment = (file: FileAttachment): ContentPart => ({
|
||||
const textAttachment = (file: FileAttachment): TextPart => ({
|
||||
type: "text",
|
||||
text: `\n\n${[
|
||||
`Attached file: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}`,
|
||||
@@ -46,7 +48,7 @@ const textAttachment = (file: FileAttachment): ContentPart => ({
|
||||
.join("\n")}`,
|
||||
})
|
||||
|
||||
const directoryAttachment = (file: FileAttachment): ContentPart => ({
|
||||
const directoryAttachment = (file: FileAttachment): TextPart => ({
|
||||
type: "text",
|
||||
text: `\n\n${[
|
||||
`Attached directory: ${attachmentLocation(file) ?? file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
|
||||
@@ -58,7 +60,7 @@ const directoryAttachment = (file: FileAttachment): ContentPart => ({
|
||||
.join("\n")}`,
|
||||
})
|
||||
|
||||
const attachmentContent = (file: FileAttachment): ContentPart[] => {
|
||||
const attachmentContent = (file: FileAttachment): Array<TextPart | MediaPart> => {
|
||||
if (file.mime === "text/plain") return [textAttachment(file)]
|
||||
if (file.mime === "application/x-directory") return [directoryAttachment(file)]
|
||||
if (imageMimes.has(file.mime) || file.mime === "application/pdf") {
|
||||
@@ -100,7 +102,7 @@ const toolInput = (tool: SessionMessage.AssistantTool) =>
|
||||
? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input)
|
||||
: tool.state.input
|
||||
|
||||
const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined): ContentPart =>
|
||||
const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined): ToolCallPart =>
|
||||
ToolCallPart.make({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
@@ -144,7 +146,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
|
||||
const reasoningInterrupted = message.content.some(
|
||||
(item) => item.type === "reasoning" && item.time !== undefined && item.time.completed === undefined,
|
||||
)
|
||||
const content = message.content.flatMap((item): ContentPart[] => {
|
||||
const content = message.content.flatMap((item): Array<AssistantMessage["content"][number]> => {
|
||||
if (item.type === "text")
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -149,14 +149,15 @@ describe("SessionModelRequest.unsupportedParts", () => {
|
||||
],
|
||||
},
|
||||
})
|
||||
const unsupported = Message.make({ role: "tool", content: [tool, Message.text("caption")] })
|
||||
const untouched = ToolResultPart.make({ id: "call_3", name: "read", result: "done" })
|
||||
const unsupported = Message.make({ role: "tool", content: [tool, untouched] })
|
||||
const result = unsupportedParts([text, supported, unsupported], capabilities(["text"]))
|
||||
|
||||
expect(result[0]).toBe(text)
|
||||
expect(result[1]).toBe(supported)
|
||||
expect(result[2]).not.toBe(unsupported)
|
||||
expect(result[2]).toBeInstanceOf(ToolMessage)
|
||||
expect(result[2]?.content[1]).toEqual(Message.text("caption"))
|
||||
expect(result[2]?.content[1]).toEqual(untouched)
|
||||
expect(result[2]?.content[0]).toMatchObject({
|
||||
type: "tool-result",
|
||||
result: {
|
||||
|
||||
@@ -1791,7 +1791,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(
|
||||
s.requests
|
||||
.at(-1)
|
||||
?.messages.flatMap((message) => message.content)
|
||||
?.messages.flatMap((message) => [...message.content])
|
||||
.some((part) => part.type === "compaction"),
|
||||
).toBe(false)
|
||||
expect(s.requests.at(-1)?.messages[0]?.content).toEqual([Message.text("Original request")])
|
||||
|
||||
Reference in New Issue
Block a user