275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
export * as SessionModelRequest from "./model-request"
|
|
|
|
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
|
import type { StreamOptions } from "@opencode-ai/ai/route"
|
|
import type { Content } from "@opencode-ai/schema/tool"
|
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
|
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
|
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
|
import { App } from "../app"
|
|
import { Model } from "../model"
|
|
import { Permission } from "../permission"
|
|
import { PluginHooks } from "../plugin/hooks"
|
|
import { QuestionTool } from "../tool/plugin/question"
|
|
import { Tool } from "../tool"
|
|
import { SessionContext } from "./context"
|
|
import { SessionModelHeaders } from "./model-headers"
|
|
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics"
|
|
import { MAX_STEPS_PROMPT } from "./runner/max-steps"
|
|
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
|
import { toLLMMessages } from "./runner/to-llm-message"
|
|
|
|
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
|
|
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
|
|
const IMAGE_REMOVED =
|
|
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
|
|
|
|
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
|
|
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
|
|
|
|
// User declines dive under the leaves' blanket `mapError` as defects (the deliberate
|
|
// tunnel entered in Permission.assert and the question tool), so a user's "no" can
|
|
// never become model-facing tool output. They resurface as typed failures exactly once,
|
|
// here at the seam the runner executes through.
|
|
const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
|
|
const decline = cause.reasons.flatMap((reason) =>
|
|
Cause.isDieReason(reason) &&
|
|
(reason.defect instanceof Permission.DeclinedError || reason.defect instanceof QuestionTool.CancelledError)
|
|
? [reason.defect]
|
|
: [],
|
|
)[0]
|
|
return decline ? Result.succeed(decline) : Result.fail(cause)
|
|
}
|
|
|
|
interface Prepared {
|
|
readonly request: LLMRequest
|
|
readonly options: StreamOptions
|
|
/**
|
|
* One request-scoped execution operation. Unknown, hook-removed, and
|
|
* step-limit-violating calls fail individually through the same seam.
|
|
*/
|
|
readonly executeTool: (
|
|
input: Parameters<Tool.Snapshot["execute"]>[0],
|
|
) => Effect.Effect<Tool.Result, ExecuteError>
|
|
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */
|
|
readonly stepLimitReached: boolean
|
|
}
|
|
|
|
interface PrepareInput {
|
|
readonly context: SessionContext.Loaded
|
|
readonly step: number
|
|
}
|
|
|
|
const mimeToModality = (mime: string) => {
|
|
if (mime.startsWith("image/")) return "image"
|
|
if (mime.startsWith("audio/")) return "audio"
|
|
if (mime.startsWith("video/")) return "video"
|
|
if (mime === "application/pdf") return "pdf"
|
|
}
|
|
|
|
const unsupportedMedia = (mime: string, name: string | undefined, capabilities: Model.Capabilities) => {
|
|
const modality = mimeToModality(mime)
|
|
if (!modality || capabilities.input.some((item) => item.startsWith(modality))) return
|
|
return {
|
|
type: "text" as const,
|
|
text: `ERROR: Cannot read ${name ? `"${name}"` : modality} (this model does not support ${modality} input). Inform the user.`,
|
|
}
|
|
}
|
|
|
|
export const unsupportedParts = (messages: LLMRequest["messages"], capabilities: Model.Capabilities) =>
|
|
messages.map((message) =>
|
|
Message.make({
|
|
...message,
|
|
content: message.content.map((part) => {
|
|
if (part.type === "media") {
|
|
return unsupportedMedia(part.mediaType, part.filename, capabilities) ?? part
|
|
}
|
|
if (part.type !== "tool-result" || part.result.type !== "content") return part
|
|
return {
|
|
...part,
|
|
result: {
|
|
...part.result,
|
|
value: part.result.value.map((item: Content) => {
|
|
if (item.type !== "file") return item
|
|
return unsupportedMedia(item.mime, item.name, capabilities) ?? item
|
|
}),
|
|
},
|
|
}
|
|
}),
|
|
}),
|
|
)
|
|
|
|
export const boundImages = (messages: LLMRequest["messages"]) => {
|
|
const isImage = (mime: string) => mime.toLowerCase().startsWith("image/")
|
|
const size = (data: string | Uint8Array) =>
|
|
typeof data === "string" ? Buffer.byteLength(data) : Math.ceil(data.byteLength / 3) * 4
|
|
const imageBytes = messages.reduce(
|
|
(total, message) =>
|
|
total +
|
|
message.content.reduce((sum, part) => {
|
|
if (part.type === "media" && isImage(part.mediaType)) return sum + size(part.data)
|
|
if (part.type !== "tool-result" || part.result.type !== "content") return sum
|
|
return (
|
|
sum +
|
|
part.result.value.reduce(
|
|
(bytes: number, item: Content) =>
|
|
bytes + (item.type === "file" && isImage(item.mime) ? Buffer.byteLength(item.uri) : 0),
|
|
0,
|
|
)
|
|
)
|
|
}, 0),
|
|
0,
|
|
)
|
|
if (imageBytes <= IMAGE_BYTES_TRIGGER) return messages
|
|
|
|
let removed = 0
|
|
return messages.map((message) =>
|
|
Message.make({
|
|
...message,
|
|
content: message.content.map((part) => {
|
|
if (part.type === "media" && isImage(part.mediaType) && imageBytes - removed > IMAGE_BYTES_TARGET) {
|
|
removed += size(part.data)
|
|
return Message.text(IMAGE_REMOVED)
|
|
}
|
|
if (part.type !== "tool-result" || part.result.type !== "content") return part
|
|
return {
|
|
...part,
|
|
result: {
|
|
...part.result,
|
|
value: part.result.value.map((item: Content) => {
|
|
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET)
|
|
return item
|
|
removed += Buffer.byteLength(item.uri)
|
|
return { type: "text" as const, text: IMAGE_REMOVED }
|
|
}),
|
|
},
|
|
}
|
|
}),
|
|
}),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Builds an outbound model request and captures the tool-call capability that
|
|
* must remain paired with it. It does not execute the request or mutate
|
|
* Session state.
|
|
*/
|
|
export interface Interface {
|
|
/** Builds one outbound model request and its matching tool-call capability. */
|
|
readonly prepare: (input: PrepareInput) => Effect.Effect<Prepared>
|
|
}
|
|
|
|
/** Location-scoped outbound model-request preparation. */
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionModelRequest") {}
|
|
|
|
export const layer = Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
const hooks = yield* PluginHooks.Service
|
|
const app = yield* App.Metadata
|
|
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
|
|
Config.withDefault(false),
|
|
Effect.orDie,
|
|
)
|
|
const promptCacheSnapshots = diagnostics ? new Map<string, PromptCacheDiagnostics.Snapshot>() : undefined
|
|
|
|
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
|
|
const session = input.context.session
|
|
const agent = input.context.agent
|
|
const resolved = input.context.model
|
|
const model = resolved.model
|
|
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
|
|
const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps
|
|
// The final Step keeps definitions available to protocols with native "none",
|
|
// preserving their prompt cache prefix. Calls are still rejected at execution.
|
|
const tools = input.context.tools
|
|
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
|
const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial]
|
|
.filter((part) => part.length > 0)
|
|
.map(SystemPart.make)
|
|
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
|
|
const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history
|
|
const toolDefinitions = tools.definitions
|
|
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
|
|
// Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit.
|
|
const contextEvent = yield* hooks.trigger("session", "context", {
|
|
sessionID: session.id,
|
|
agent: agent.id,
|
|
model: resolved.ref,
|
|
system,
|
|
messages,
|
|
tools: Object.fromEntries(
|
|
toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
|
|
),
|
|
})
|
|
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
|
|
const registered = toolsByName.get(name)
|
|
return registered
|
|
? [{ ...registered, description: tool.description, inputSchema: tool.input }]
|
|
: []
|
|
})
|
|
const request = LLM.request({
|
|
model,
|
|
http: {
|
|
headers: SessionModelHeaders.make(session, app),
|
|
},
|
|
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
|
|
system: contextEvent.system,
|
|
messages: boundImages(unsupportedParts(contextEvent.messages, resolved.capabilities)),
|
|
tools: hookedTools,
|
|
toolChoice: stepLimitReached ? "none" : undefined,
|
|
})
|
|
const options: StreamOptions = {
|
|
http: (request, handler) =>
|
|
hooks
|
|
.trigger("session", "http", {
|
|
sessionID: session.id,
|
|
agent: agent.id,
|
|
model: resolved.ref,
|
|
request: handler,
|
|
})
|
|
.pipe(Effect.flatMap((event) => event.request(request))),
|
|
}
|
|
if (promptCacheSnapshots) {
|
|
const current = PromptCacheDiagnostics.snapshot(request)
|
|
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(session.id), current)
|
|
promptCacheSnapshots.delete(session.id)
|
|
promptCacheSnapshots.set(session.id, current)
|
|
const oldest = promptCacheSnapshots.keys().next().value
|
|
if (promptCacheSnapshots.size > 100 && oldest !== undefined) promptCacheSnapshots.delete(oldest)
|
|
yield* Effect.logInfo("prompt cache prefix").pipe(
|
|
Effect.annotateLogs({
|
|
sessionID: session.id,
|
|
toolCount: current.tools.length,
|
|
systemParts: current.system.length,
|
|
messageCount: current.messages.length,
|
|
...comparison,
|
|
}),
|
|
)
|
|
}
|
|
const executeTool: Prepared["executeTool"] = (executeInput) => {
|
|
if (stepLimitReached)
|
|
return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
|
if (toolsByName.has(executeInput.call.name) && !Object.hasOwn(contextEvent.tools, executeInput.call.name))
|
|
return new Tool.Error({ message: `Tool is not available for this request: ${executeInput.call.name}` })
|
|
return tools
|
|
.execute(executeInput)
|
|
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
|
|
}
|
|
return {
|
|
request,
|
|
options,
|
|
executeTool,
|
|
stepLimitReached,
|
|
}
|
|
})
|
|
|
|
return Service.of({ prepare })
|
|
}),
|
|
)
|
|
|
|
export const node = makeLocationNode({
|
|
service: Service,
|
|
layer,
|
|
deps: [PluginHooks.node, App.node],
|
|
})
|