Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton 53a93aa494 refactor(core): simplify request preparation 2026-08-14 23:17:38 -04:00
Kit Langton 153d7a9719 refactor(core): share session model requests 2026-08-14 21:00:40 -04:00
6 changed files with 154 additions and 122 deletions
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Apply shared Session model-request preparation to transient generation.
+21 -52
View File
@@ -1,85 +1,54 @@
export * as SessionGenerateNode from "./generate-node.js"
import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai"
import { LLMClient, Message } from "@opencode-ai/ai"
import { Effect, Layer } from "effect"
import { Database } from "../database/database.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app.js"
import { llmClient } from "../effect/app-node-platform.js"
import { PluginHooks } from "../plugin/hooks.js"
import { SessionContext } from "./context.js"
import { SessionGenerate } from "./generate.js"
import { SessionHistory } from "./history.js"
import { SessionModelHeaders } from "./model-headers.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
import { SessionSystemPrompt } from "./system-prompt.js"
import { toLLMMessages } from "./runner/to-llm-message.js"
export const layer = Layer.effect(
SessionGenerate.Service,
Effect.gen(function* () {
const context = yield* SessionContext.Service
const database = yield* Database.Service
const hooks = yield* PluginHooks.Service
const llm = yield* LLMClient.Service
const models = yield* SessionRunnerModel.Service
const app = yield* App.Metadata
const modelRequests = yield* SessionModelRequest.Service
return SessionGenerate.Service.of({
generate: Effect.fn("SessionGenerate.generate")(function* (input) {
const selection = yield* context.select(input.sessionID)
const model = yield* models.resolve(selection.session)
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
const providerMetadataKey = model.model.route.providerMetadataKey ?? model.model.provider
const tools = selection.tools
const toolDefinitions = tools.definitions
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
const contextEvent = yield* hooks.trigger("session", "context", {
sessionID: selection.session.id,
agent: selection.agent.id,
model: model.ref,
system: [
selection.agent.info.system
? selection.agent.info.system
: SessionSystemPrompt.make(toolDefinitions.map((tool) => tool.name)),
history.initial,
]
.filter((part) => part.length > 0)
.map(SystemPart.make),
messages: [
...toLLMMessages(history.messages, model.ref, providerMetadataKey),
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
Message.user(input.prompt),
],
tools: Object.fromEntries(
toolDefinitions.map((tool) => [
tool.name,
{ description: tool.description, input: { ...tool.inputSchema } },
]),
),
const transcript = SessionModelRequest.baseTranscript({
agent: selection.agent.info,
model,
tools: selection.tools,
initial: history.initial,
messages: history.messages,
})
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
const registered = toolsByName.get(name)
return registered
? [Object.assign({}, registered, { description: tool.description, inputSchema: tool.input })]
: []
const prepared = yield* modelRequests.prepare({
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
transcript: {
system: transcript.system,
messages: [
...transcript.messages,
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
Message.user(input.prompt),
],
},
})
yield* Effect.logInfo("sending session generation request", {
sessionID: selection.session.id,
providerID: model.ref.providerID,
modelID: model.ref.id,
})
const response = yield* llm.generate(
LLM.request({
model: model.model,
http: { headers: SessionModelHeaders.make(selection.session, app) },
promptCacheKey: SessionPromptCacheKey.make(selection.session.id),
system: contextEvent.system,
messages: contextEvent.messages,
tools: hookedTools,
}),
)
const response = yield* llm.generate(prepared.request, prepared.options)
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
return response.text
}),
@@ -90,5 +59,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: SessionGenerate.Service,
layer,
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, App.node, llmClient],
deps: [SessionContext.node, Database.node, SessionModelRequest.node, SessionRunnerModel.node, llmClient],
})
+51 -59
View File
@@ -12,15 +12,16 @@ import { Permission } from "../permission.js"
import { PluginHooks } from "../plugin/hooks.js"
import { QuestionTool } from "../tool/plugin/question.js"
import { Tool } from "../tool.js"
import { SessionContext } from "./context.js"
import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHttp } from "./model-http.js"
import { SessionModelTransport } from "./model-transport.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics.js"
import { MAX_STEPS_PROMPT } from "./runner/max-steps.js"
import { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { SessionSystemPrompt } from "./system-prompt.js"
import { toLLMMessages } from "./runner/to-llm-message.js"
import type { SessionMessage } from "./message.js"
import type { Agent } from "../agent.js"
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
@@ -47,20 +48,49 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
interface Prepared {
readonly request: LLMRequest
readonly options: StreamOptions
/** False when Session HTTP hooks require the request to remain on HTTP. */
readonly webSocketEligible: boolean
/**
* One request-scoped execution operation. Unknown, hook-removed, and
* step-limit-violating calls fail individually through the same seam.
* One request-scoped execution operation. Unknown and hook-removed 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
readonly scope: {
readonly session: SessionSchema.Info
readonly agentID: Agent.ID
readonly model: SessionRunnerModel.Resolved
readonly tools: Tool.Snapshot
}
readonly transcript: {
readonly system: Array<SystemPart>
readonly messages: Array<Message>
}
readonly toolChoice?: LLM.RequestInput["toolChoice"]
/** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */
readonly webSocket?: "session"
}
export const baseTranscript = (input: {
readonly agent: Agent.Info
readonly model: SessionRunnerModel.Resolved
readonly tools: Tool.Snapshot
readonly initial: string
readonly messages: ReadonlyArray<SessionMessage.Info>
}) => {
const providerMetadataKey = input.model.model.route.providerMetadataKey ?? input.model.model.provider
return {
providerMetadataKey,
system: [
input.agent.system
? input.agent.system
: SessionSystemPrompt.make(input.tools.definitions.map((tool) => tool.name)),
input.initial,
]
.filter((part) => part.length > 0)
.map(SystemPart.make),
messages: toLLMMessages(input.messages, input.model.ref, providerMetadataKey),
}
}
const mimeToModality = (mime: string) => {
@@ -174,30 +204,11 @@ export const layer = Layer.effect(
Config.withDefault(false),
Effect.orDie,
)
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 session = input.scope.session
const resolved = input.scope.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 system = [
agent.info.system ? agent.info.system : SessionSystemPrompt.make(tools.definitions.map((tool) => tool.name)),
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 tools = input.scope.tools
const registry = new Map(tools.definitions.map((tool) => [tool.name, tool]))
// The definition objects we hand to hooks, mapped back to their tools. Hooks rename a
// tool by moving its definition to a new key; recognizing the object recovers the tool.
@@ -209,10 +220,10 @@ export const layer = Layer.effect(
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
const context = yield* hooks.trigger("session", "context", {
sessionID: session.id,
agent: agent.id,
agent: input.scope.agentID,
model: resolved.ref,
system,
messages,
system: input.transcript.system,
messages: input.transcript.messages,
tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])),
})
// Match each surviving entry back to its tool, by recognizing a moved definition or
@@ -235,7 +246,7 @@ export const layer = Layer.effect(
system: context.system,
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
toolChoice: stepLimitReached ? "none" : undefined,
toolChoice: input.toolChoice,
})
const webSocketEligible =
!(yield* hooks.has("session", "http.request")) && !(yield* hooks.has("session", "http.response"))
@@ -243,37 +254,20 @@ export const layer = Layer.effect(
? undefined
: SessionModelHttp.middleware(hooks, {
sessionID: session.id,
agent: agent.id,
agent: input.scope.agentID,
model: resolved.ref,
})
const options: StreamOptions = {
...(http ? { http } : {}),
...(webSocket &&
...(input.webSocket === "session" &&
webSocket &&
webSocketEligible &&
resolved.ref.providerID === Provider.ID.openai &&
model.route.id === "openai-responses"
? { webSocket: transport.bind(session.id) }
: {}),
}
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"] = (input) => {
if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
const tool = hooked.get(input.call.name)
// A registered tool absent from the hooked set was removed or renamed by a hook.
if (!tool && registry.has(input.call.name))
@@ -285,9 +279,7 @@ export const layer = Layer.effect(
return {
request,
options,
webSocketEligible,
executeTool,
stepLimitReached,
}
})
+59 -7
View File
@@ -4,11 +4,12 @@ import {
LLMClient,
AIError,
LLMEvent,
Message,
isContextOverflowFailure,
type ProviderErrorEvent,
type ToolCall,
} from "@opencode-ai/ai"
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
import { Cause, Config, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
import { Permission } from "../../permission.js"
@@ -34,6 +35,9 @@ import { toSessionError } from "../to-session-error.js"
import { SessionRunnerRetry } from "./retry.js"
import { SessionUsage } from "../usage.js"
import { ToolOutput } from "../../tool-output.js"
import { Tool } from "../../tool.js"
import { PromptCacheDiagnostics } from "../prompt-cache-diagnostics.js"
import { MAX_STEPS_PROMPT } from "./max-steps.js"
/** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */
type CallOutcome = Data.TaggedEnum<{
@@ -116,6 +120,32 @@ const layer = Layer.effect(
const compaction = yield* SessionCompaction.Service
const title = yield* SessionTitle.Service
const toolOutput = yield* ToolOutput.Service
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 diagnosePromptCache = Effect.fn("SessionRunner.diagnosePromptCache")(function* (
sessionID: SessionSchema.ID,
request: Parameters<typeof PromptCacheDiagnostics.snapshot>[0],
) {
if (!promptCacheSnapshots) return
const current = PromptCacheDiagnostics.snapshot(request)
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(sessionID), current)
promptCacheSnapshots.delete(sessionID)
promptCacheSnapshots.set(sessionID, 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,
toolCount: current.tools.length,
systemParts: current.system.length,
messageCount: current.messages.length,
...comparison,
}),
)
})
// Title generation starts once input is visible and must not delay model execution.
// The in-flight set coalesces overlapping prompts while title presence records success durably.
const titlesRunning = new Set<SessionSchema.ID>()
@@ -278,10 +308,32 @@ const layer = Layer.effect(
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
return yield* new StepFailedError({ error: compacted.error })
}
const prepared = yield* modelRequests.prepare({
context: loaded,
step: currentStep,
const stepLimitReached = agent.info.steps !== undefined && currentStep >= agent.info.steps
const transcript = SessionModelRequest.baseTranscript({
agent: agent.info,
model: resolved,
tools: loaded.tools,
initial: loaded.initial,
messages: loaded.messages,
})
const prepared = yield* modelRequests.prepare({
scope: { session, agentID: agent.id, model: resolved, tools: loaded.tools },
transcript: {
system: transcript.system,
messages: stepLimitReached
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
: transcript.messages,
},
// The final Step keeps definitions available to protocols with native "none",
// preserving their prompt cache prefix. Calls are still rejected at execution.
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
})
yield* diagnosePromptCache(session.id, prepared.request)
const executeTool = (input: Parameters<typeof prepared.executeTool>[0]) => {
if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
return prepared.executeTool(input)
}
// Every local tool call forked here is owned until it reaches one durable settlement.
const toolRuns: Array<{
readonly call: ToolCall
@@ -295,7 +347,7 @@ const layer = Layer.effect(
// The selected catalog identity, not model.id: route-level ids are provider API
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
model: resolved.ref,
providerMetadataKey: model.route.providerMetadataKey ?? model.provider,
providerMetadataKey: transcript.providerMetadataKey,
snapshot: startSnapshot,
assistantMessageID,
})
@@ -356,7 +408,7 @@ const layer = Layer.effect(
call: event,
fiber: yield* Effect.uninterruptibleMask((restore) =>
restore(
prepared.executeTool({
executeTool({
sessionID: session.id,
agent: agent.id,
messageID: assistantMessageID,
@@ -504,7 +556,7 @@ const layer = Layer.effect(
// A local call or malformed tool input requires another model step, unless
// this step already exhausted the agent's allowance.
needsContinuation:
!prepared.stepLimitReached &&
!stepLimitReached &&
record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)),
step: currentStep,
})
+8 -1
View File
@@ -9,6 +9,7 @@ import {
type LLMRequest,
} from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { Agent } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -51,15 +52,17 @@ import { Effect, Layer, Schema, Stream } from "effect"
import { testEffect } from "./lib/effect"
const requests: LLMRequest[] = []
const options: Array<StreamOptions | undefined> = []
let instruction: string | Instructions.Unavailable = "Initial context"
const sessionID = SessionSchema.ID.make("ses_generate_test")
const model = LanguageModel.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
const client = Layer.mock(LLMClient.Service)({
stream: () => Stream.die(new Error("unused")),
generate: (request) =>
generate: (request, requestOptions) =>
Effect.sync(() => {
requests.push(request)
options.push(requestOptions)
const response = LLMResponse.fromEvents([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "generate" }),
@@ -221,6 +224,7 @@ const setup = Effect.gen(function* () {
it.effect("generates from fresh settled Session context without durable mutation", () =>
Effect.gen(function* () {
requests.length = 0
options.length = 0
instruction = "Initial context"
const { db, bus, instructions } = yield* setup
yield* InstructionState.prepare(db, bus, instructions, sessionID)
@@ -292,6 +296,7 @@ it.effect("generates from fresh settled Session context without durable mutation
if (event.tools.lookup) event.tools.lookup.description = "Hooked lookup"
}),
)
yield* hooks.register("session", "http.request", () => Effect.void)
const generate = yield* SessionGenerate.Service
const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" })
@@ -321,6 +326,8 @@ it.effect("generates from fresh settled Session context without durable mutation
).toEqual(["Settled partial answer"])
expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }])
expect(requests[0]?.toolChoice).toBeUndefined()
expect(options[0]?.http).toBeFunction()
expect(options[0]?.webSocket).toBeUndefined()
expect(yield* durableState(db, sessionID)).toEqual(before)
}),
)
+10 -3
View File
@@ -1004,9 +1004,16 @@ describe("SessionRunnerLLM", () => {
const database = yield* Database.Service
const bus = yield* Bus.Service
yield* InstructionState.prepare(database.db, bus, selected.instructions, sessionID)
const loaded = yield* context.load(selected)
const prepared = yield* modelRequests.prepare({
context: yield* context.load(selected),
step: 1,
scope: {
session: loaded.session,
agentID: loaded.agent.id,
model: loaded.model,
tools: loaded.tools,
},
transcript: { system: [], messages: [] },
webSocket: "session",
})
const http = prepared.options.http ?? (yield* Effect.die("Expected Session HTTP middleware"))
@@ -1015,7 +1022,7 @@ describe("SessionRunnerLLM", () => {
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("network")))
})
expect(prepared.webSocketEligible).toBe(false)
expect(prepared.options.webSocket).toBeUndefined()
expect(response.headers["x-response-hook"]).toBe("active")
expect(requestTriggers).toBe(1)
expect(responseTriggers).toBe(1)