171 lines
5.7 KiB
TypeScript
171 lines
5.7 KiB
TypeScript
import {
|
|
Message,
|
|
ToolCallPart,
|
|
ToolOutput,
|
|
ToolResultPart,
|
|
type ContentPart,
|
|
type Model,
|
|
type ProviderMetadata,
|
|
} from "@opencode-ai/llm"
|
|
import { SessionMessage } from "../message"
|
|
import type { FileAttachment } from "../prompt"
|
|
|
|
const media = (file: FileAttachment): ContentPart => ({
|
|
type: "media",
|
|
mediaType: file.mime,
|
|
data: file.uri,
|
|
filename: file.name,
|
|
metadata: file.description === undefined ? undefined : { description: file.description },
|
|
})
|
|
|
|
const toolInput = (tool: SessionMessage.AssistantTool) => {
|
|
if (tool.state.status !== "pending") return tool.state.input
|
|
try {
|
|
return JSON.parse(tool.state.input) as unknown
|
|
} catch {
|
|
return tool.state.input
|
|
}
|
|
}
|
|
|
|
const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined): ContentPart =>
|
|
ToolCallPart.make({
|
|
id: tool.id,
|
|
name: tool.name,
|
|
input: toolInput(tool),
|
|
providerExecuted: tool.provider?.executed,
|
|
providerMetadata,
|
|
})
|
|
|
|
const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined) => {
|
|
if (tool.state.status === "completed") {
|
|
// TODO: Materialize remote and managed URIs before provider-history lowering.
|
|
// ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes.
|
|
const result =
|
|
tool.provider?.executed === true && tool.state.result !== undefined
|
|
? tool.state.result
|
|
: ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content })
|
|
return ToolResultPart.make({
|
|
id: tool.id,
|
|
name: tool.name,
|
|
result,
|
|
providerExecuted: tool.provider?.executed,
|
|
providerMetadata,
|
|
})
|
|
}
|
|
if (tool.state.status === "error") {
|
|
return ToolResultPart.make({
|
|
id: tool.id,
|
|
name: tool.name,
|
|
result:
|
|
tool.provider?.executed === true && tool.state.result !== undefined
|
|
? tool.state.result
|
|
: { error: tool.state.error, content: tool.state.content, structured: tool.state.structured },
|
|
resultType: "error",
|
|
providerExecuted: tool.provider?.executed,
|
|
providerMetadata,
|
|
})
|
|
}
|
|
}
|
|
|
|
const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
|
const sameModel =
|
|
String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
|
|
const reuseProviderMetadata = sameModel && message.error === undefined
|
|
const content = message.content.flatMap((item): ContentPart[] => {
|
|
if (item.type === "text") return [{ type: "text", text: item.text }]
|
|
if (item.type === "reasoning")
|
|
return sameModel
|
|
? [
|
|
{
|
|
type: "reasoning",
|
|
text: item.text,
|
|
providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined,
|
|
},
|
|
]
|
|
: item.text.length > 0
|
|
? [{ type: "text", text: item.text }]
|
|
: []
|
|
const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined)
|
|
if (item.provider?.executed !== true) return [call]
|
|
const result = toolResult(
|
|
item,
|
|
reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined,
|
|
)
|
|
return result ? [call, result] : [call]
|
|
})
|
|
const meaningful = content.filter((part) => {
|
|
if (part.type === "text") return part.text !== ""
|
|
if (part.type !== "reasoning") return true
|
|
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
|
|
})
|
|
const results = message.content
|
|
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
|
|
.map((item) =>
|
|
toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined),
|
|
)
|
|
.filter((message) => message !== undefined)
|
|
.map(Message.tool)
|
|
if (meaningful.length === 0) return results
|
|
return [
|
|
Message.make({ id: message.id, role: "assistant", content: meaningful, metadata: message.metadata }),
|
|
...results,
|
|
]
|
|
}
|
|
|
|
function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] {
|
|
switch (message.type) {
|
|
case "agent-switched":
|
|
case "model-switched":
|
|
return []
|
|
case "user":
|
|
return [
|
|
Message.make({
|
|
id: message.id,
|
|
role: "user",
|
|
content: [{ type: "text", text: message.text }, ...(message.files ?? []).map(media)],
|
|
metadata: {
|
|
...message.metadata,
|
|
...(message.agents?.length ? { agents: message.agents } : {}),
|
|
},
|
|
}),
|
|
]
|
|
case "synthetic":
|
|
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
|
|
case "system":
|
|
return [Message.system(message.text)]
|
|
case "shell":
|
|
return [
|
|
Message.make({
|
|
id: message.id,
|
|
role: "user",
|
|
content: `Shell command: ${message.command}\n\n${message.output}`,
|
|
metadata: message.metadata,
|
|
}),
|
|
]
|
|
case "assistant":
|
|
return assistant(message, model)
|
|
case "compaction":
|
|
return [
|
|
Message.make({
|
|
id: message.id,
|
|
role: "user",
|
|
content: `<conversation-checkpoint>
|
|
The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.
|
|
|
|
<summary>
|
|
${message.summary}
|
|
</summary>
|
|
|
|
<recent-context>
|
|
${message.recent}
|
|
</recent-context>
|
|
</conversation-checkpoint>`,
|
|
metadata: message.metadata,
|
|
}),
|
|
]
|
|
}
|
|
}
|
|
|
|
/** Translate projected V2 Session history into canonical @opencode-ai/llm context. */
|
|
export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: Model) =>
|
|
messages.flatMap((message) => toLLMMessage(message, model))
|