206 lines
8.6 KiB
TypeScript
206 lines
8.6 KiB
TypeScript
export * as SessionModelRequest from "./model-request"
|
|
|
|
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
|
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"
|
|
|
|
/** 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
|
|
/**
|
|
* 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
|
|
}),
|
|
},
|
|
}
|
|
}),
|
|
}),
|
|
)
|
|
|
|
/**
|
|
* 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: { openai: { promptCacheKey } },
|
|
system: contextEvent.system,
|
|
messages: unsupportedParts(contextEvent.messages, resolved.capabilities),
|
|
tools: hookedTools,
|
|
toolChoice: stepLimitReached ? "none" : undefined,
|
|
})
|
|
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,
|
|
executeTool,
|
|
stepLimitReached,
|
|
}
|
|
})
|
|
|
|
return Service.of({ prepare })
|
|
}),
|
|
)
|
|
|
|
export const node = makeLocationNode({
|
|
service: Service,
|
|
layer,
|
|
deps: [PluginHooks.node, App.node],
|
|
})
|