refactor(core): extract session model request preparation (#37210)

This commit is contained in:
Kit Langton
2026-07-16 10:58:06 -04:00
committed by GitHub
parent 06a2592836
commit 56c0658e6f
4 changed files with 150 additions and 97 deletions
+8 -2
View File
@@ -34,13 +34,19 @@ export interface Loaded {
readonly messages: ReadonlyArray<SessionMessage.Info>
}
/**
* Resolves model-request state in two phases: `select` fixes the Session,
* agent, and instruction sources; `load` adds the model and active history for
* that selection. This module does not build or execute the model request.
*/
export interface Interface {
/** Resolves the Session, selected agent, and current instruction sources before durable Step preparation. */
/** Selects the Session, agent, and instruction sources used by subsequent work. */
readonly select: (sessionID: SessionSchema.ID) => Effect.Effect<Selection, AgentNotFoundError>
/** Loads the selected model and active history after instruction sync and pending-input promotion. */
/** Resolves the model and active history for that selection. */
readonly load: (selection: Selection) => Effect.Effect<Loaded, SessionRunnerModel.Error>
}
/** Location-scoped model-context loader for durable Session Steps. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionContext") {}
const layer = Layer.effect(
+119
View File
@@ -0,0 +1,119 @@
export * as SessionModelRequest from "./model-request"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { PluginHooks } from "../plugin/hooks"
import { ToolRegistry } from "../tool/registry"
import { SessionContext } from "./context"
import { SessionModelHeaders } from "./model-headers"
import { MAX_STEPS_PROMPT } from "./runner/max-steps"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
type ToolCallResolution =
| { readonly type: "reject"; readonly error: SessionError.Error }
| { readonly type: "settle"; readonly settle: ToolRegistry.Materialization["settle"] }
interface Prepared {
readonly request: LLMRequest
readonly resolveToolCall: (name: string) => ToolCallResolution
}
interface PrepareInput {
readonly context: SessionContext.Loaded
readonly step: number
}
/**
* 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/v2/SessionModelRequest") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const registry = yield* ToolRegistry.Service
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
const executableTools = stepLimitReached ? undefined : yield* registry.materialize(agent.info.permissions)
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 = executableTools?.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
? [Object.assign({}, registered, { description: tool.description, inputSchema: tool.input })]
: []
})
const request = LLM.request({
model,
http: {
headers: SessionModelHeaders.make(session),
},
providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system,
messages: contextEvent.messages,
tools: hookedTools,
toolChoice: stepLimitReached ? "none" : undefined,
})
const resolveToolCall = (name: string): ToolCallResolution => {
if (!executableTools)
return {
type: "reject",
error: { type: "tool.execution", message: "Tools are disabled after the maximum agent steps" },
}
if (toolsByName.has(name) && !Object.hasOwn(contextEvent.tools, name))
return {
type: "reject",
error: { type: "tool.execution", message: `Tool is not available for this request: ${name}` },
}
return { type: "settle", settle: executableTools.settle }
}
return {
request,
resolveToolCall,
}
})
return Service.of({ prepare })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [PluginHooks.node, ToolRegistry.node],
})
+14 -86
View File
@@ -1,16 +1,6 @@
export * as SessionRunnerLLM from "./llm"
import {
LLM,
LLMClient,
LLMError,
LLMEvent,
Message,
SystemPart,
isContextOverflowFailure,
type ProviderErrorEvent,
} from "@opencode-ai/ai"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
@@ -19,30 +9,25 @@ import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { PermissionV2 } from "../../permission"
import { QuestionTool } from "../../tool/question"
import { ToolRegistry } from "../../tool/registry"
import { ToolOutputStore } from "../../tool-output-store"
import { InstructionState } from "../instruction-state"
import { SessionCompaction } from "../compaction"
import { SessionContext } from "../context"
import { SessionEvent } from "../event"
import { SessionPending } from "../pending"
import { SessionModelRequest } from "../model-request"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { SessionTitle } from "../title"
import { Service } from "./index"
import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
import { MAX_STEPS_PROMPT } from "./max-steps"
import PROMPT_DEFAULT from "./prompt/base.txt"
import { Snapshot } from "../../snapshot"
import { makeLocationNode } from "../../effect/app-node"
import { llmClient } from "../../effect/app-node-platform"
import { StepFailedError } from "../error"
import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry"
import { PluginHooks } from "../../plugin/hooks"
import { SessionModelHeaders } from "../model-headers"
type StepTokens = {
readonly input: number
@@ -68,20 +53,14 @@ export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) {
)
}
/**
* Runs one durable coding-agent Session until it settles. Each step reloads projected history,
* materializes tools, makes one model request, and settles local calls before continuation.
*/
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const llm = yield* LLMClient.Service
const tools = yield* ToolRegistry.Service
const hooks = yield* PluginHooks.Service
const store = yield* SessionStore.Service
const context = yield* SessionContext.Service
const modelRequests = yield* SessionModelRequest.Service
const snapshots = yield* Snapshot.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
@@ -146,59 +125,18 @@ const layer = Layer.effect(
const loaded = yield* context.load(selected)
const session = loaded.session
const agent = loaded.agent
const agentInfo = agent.info
const resolved = loaded.model
const model = resolved.model
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
const compactionInput = { session, messages: loaded.messages, model }
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
return yield* new StepFailedError({ error: compacted.error })
}
const isLastStep = agentInfo.steps !== undefined && currentStep >= agentInfo.steps
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agentInfo.permissions)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
http: {
headers: SessionModelHeaders.make(session),
},
providerOptions: { openai: { promptCacheKey } },
system: [agentInfo.system ? agentInfo.system : PROMPT_DEFAULT, loaded.initial]
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: [
...toLLMMessages(loaded.messages, resolved.ref, providerMetadataKey),
...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []),
],
tools: toolMaterialization?.definitions ?? [],
toolChoice: isLastStep ? "none" : undefined,
const prepared = yield* modelRequests.prepare({
context: loaded,
step: currentStep,
})
const availableTools = new Map(request.tools.map((tool) => [tool.name, tool]))
const contextEvent: SessionHooks["context"] = {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
system: [...request.system],
messages: [...request.messages],
tools: Object.fromEntries(
request.tools.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
),
}
// Plugins may reshape the draft but cannot advertise tools excluded by
// permissions, registration state, or the selected agent's step limit.
yield* hooks.trigger("session", "context", contextEvent)
const hookedRequest = LLM.updateRequest(request, {
system: contextEvent.system,
messages: contextEvent.messages,
tools: Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
const registered = availableTools.get(name)
if (!registered) return []
return [{ ...registered, description: tool.description, inputSchema: tool.input }]
}),
})
const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name))
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
let needsContinuation = false
@@ -209,7 +147,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,
providerMetadataKey: model.route.providerMetadataKey ?? model.provider,
snapshot: startSnapshot,
assistantMessageID,
})
@@ -219,7 +157,7 @@ const layer = Layer.effect(
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(hookedRequest).pipe(
const providerStream = llm.stream(prepared.request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
@@ -231,15 +169,9 @@ const layer = Layer.effect(
}
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization || (availableTools.has(event.name) && !advertisedTools.has(event.name))) {
yield* serialized(
publisher.failUnsettledTools({
type: "tool.execution",
message: toolMaterialization
? `Tool is not available for this request: ${event.name}`
: "Tools are disabled after the maximum agent steps",
}),
)
const tool = prepared.resolveToolCall(event.name)
if (tool.type === "reject") {
yield* serialized(publisher.failUnsettledTools(tool.error))
return
}
needsContinuation = true
@@ -247,7 +179,7 @@ const layer = Layer.effect(
ownedToolFibers.push(
yield* Effect.uninterruptibleMask((restore) =>
restore(
toolMaterialization.settle({
tool.settle({
sessionID: session.id,
agent: agent.id,
messageID: assistantMessageID,
@@ -337,10 +269,7 @@ const layer = Layer.effect(
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
if (llmFailure && !publisher.hasProviderError()) {
const error = toSessionError(llmFailure)
if (
SessionRunnerRetry.isRetryable(llmFailure) &&
!publisher.hasRetryEvidence()
) {
if (SessionRunnerRetry.isRetryable(llmFailure) && !publisher.hasRetryEvidence()) {
return yield* new SessionRunnerRetry.RetryableFailure({
cause: llmFailure,
assistantMessageID: yield* publisher.startAssistant(),
@@ -570,9 +499,8 @@ export const node = makeLocationNode({
deps: [
EventV2.node,
llmClient,
ToolRegistry.node,
PluginHooks.node,
SessionContext.node,
SessionModelRequest.node,
SessionStore.node,
SessionCompaction.node,
SessionTitle.node,
+9 -9
View File
@@ -39,13 +39,13 @@ session.prompt
-> SessionAdmission
-> SessionExecution
-> SessionContext.snapshot
-> SessionRequest.prepare
-> SessionModelRequest.prepare
-> LLMClient.stream
-> SessionSettlement
session.generate
-> SessionContext.snapshot
-> SessionRequest.prepare
-> SessionModelRequest.prepare
-> LLMClient.generate
-> return text
```
@@ -54,7 +54,7 @@ The modules have distinct jobs:
- `SessionAdmission` records and promotes durable input.
- `SessionContext` resolves a read-only, internally consistent view of what the selected agent would see.
- `SessionRequest` converts that view into the provider request used by a Physical Attempt.
- `SessionModelRequest` converts that view into the provider request used by a Physical Attempt.
- `LLMClient` executes one provider request without deciding what becomes durable.
- `SessionSettlement` gives streamed provider events their durable Session meaning, executes local tools, records usage, and decides continuation.
- `SessionCompaction` replaces oversized active history and remains a durable Session operation.
@@ -66,16 +66,16 @@ Durability becomes a property of admission and settlement, not request preparati
The first extraction should be one internal Location-scoped module with a small interface. Names are provisional; behavior is not.
```ts
interface SessionRequest {
interface SessionModelRequest {
readonly prepare: (input: {
snapshot: SessionContext.Snapshot
operation: { type: "step"; current: number; maximum?: number } | { type: "generate"; prompt: Message.User }
}) => Effect<PreparedSessionRequest, SessionRequestError>
}) => Effect<PreparedSessionModelRequest, SessionModelRequestError>
}
```
```ts
type PreparedSessionRequest = {
type PreparedSessionModelRequest = {
request: LLM.Request
snapshot: SessionContext.Snapshot
executableTools?: MaterializedTools
@@ -154,7 +154,7 @@ The prepared result carries the captured revision internally. A later recap inte
The existing Session context hook should run because it participates in normal request preparation. Its event should eventually identify the operation:
```ts
type SessionRequestOperation = { type: "step"; step: number } | { type: "generate" } | { type: "compaction" }
type SessionModelRequestOperation = { type: "step"; step: number } | { type: "generate" } | { type: "compaction" }
```
Request preparation and observation hooks run for `session.generate`. Admission, projection, Session settlement, and tool-execution hooks do not run because those stages do not occur.
@@ -211,9 +211,9 @@ Move instruction source loading and read-only assembly behind one internal inter
This commit should not add `session.generate`.
### 3. Extract Session Request Preparation
### 3. Extract Session Model Request Preparation
Move model resolution, history selection, request construction, tool definitions, cache identity, and context hooks into the Location-scoped `SessionRequest` module. Make the durable runner its only caller first.
Move model resolution, history selection, request construction, tool definitions, cache identity, and context hooks into the Location-scoped `SessionModelRequest` module. Make the durable runner its only caller first.
Verify the recorded normal request before and after extraction. Keep compaction detection and pending promotion outside the new module if putting them inside would make preparation mutate state.