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> 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 { 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> 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> 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") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionContext") {}
const layer = Layer.effect( 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" export * as SessionRunnerLLM from "./llm"
import { import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
LLM,
LLMClient,
LLMError,
LLMEvent,
Message,
SystemPart,
isContextOverflowFailure,
type ProviderErrorEvent,
} from "@opencode-ai/ai"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { SessionError } from "@opencode-ai/schema/session-error" import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect" 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 { ModelV2 } from "../../model"
import { PermissionV2 } from "../../permission" import { PermissionV2 } from "../../permission"
import { QuestionTool } from "../../tool/question" import { QuestionTool } from "../../tool/question"
import { ToolRegistry } from "../../tool/registry"
import { ToolOutputStore } from "../../tool-output-store" import { ToolOutputStore } from "../../tool-output-store"
import { InstructionState } from "../instruction-state" import { InstructionState } from "../instruction-state"
import { SessionCompaction } from "../compaction" import { SessionCompaction } from "../compaction"
import { SessionContext } from "../context" import { SessionContext } from "../context"
import { SessionEvent } from "../event" import { SessionEvent } from "../event"
import { SessionPending } from "../pending" import { SessionPending } from "../pending"
import { SessionModelRequest } from "../model-request"
import { SessionMessage } from "../message" import { SessionMessage } from "../message"
import { SessionSchema } from "../schema" import { SessionSchema } from "../schema"
import { SessionStore } from "../store" import { SessionStore } from "../store"
import { SessionTitle } from "../title" import { SessionTitle } from "../title"
import { Service } from "./index" import { Service } from "./index"
import { createLLMEventPublisher } from "./publish-llm-event" 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 { Snapshot } from "../../snapshot"
import { makeLocationNode } from "../../effect/app-node" import { makeLocationNode } from "../../effect/app-node"
import { llmClient } from "../../effect/app-node-platform" import { llmClient } from "../../effect/app-node-platform"
import { StepFailedError } from "../error" import { StepFailedError } from "../error"
import { toSessionError } from "../to-session-error" import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry" import { SessionRunnerRetry } from "./retry"
import { PluginHooks } from "../../plugin/hooks"
import { SessionModelHeaders } from "../model-headers"
type StepTokens = { type StepTokens = {
readonly input: number 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( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const llm = yield* LLMClient.Service const llm = yield* LLMClient.Service
const tools = yield* ToolRegistry.Service
const hooks = yield* PluginHooks.Service
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const context = yield* SessionContext.Service const context = yield* SessionContext.Service
const modelRequests = yield* SessionModelRequest.Service
const snapshots = yield* Snapshot.Service const snapshots = yield* Snapshot.Service
const db = (yield* Database.Service).db const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service const compaction = yield* SessionCompaction.Service
@@ -146,59 +125,18 @@ const layer = Layer.effect(
const loaded = yield* context.load(selected) const loaded = yield* context.load(selected)
const session = loaded.session const session = loaded.session
const agent = loaded.agent const agent = loaded.agent
const agentInfo = agent.info
const resolved = loaded.model const resolved = loaded.model
const model = resolved.model const model = resolved.model
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
const compactionInput = { session, messages: loaded.messages, model } const compactionInput = { session, messages: loaded.messages, model }
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) { if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput) const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
return yield* new StepFailedError({ error: compacted.error }) return yield* new StepFailedError({ error: compacted.error })
} }
const isLastStep = agentInfo.steps !== undefined && currentStep >= agentInfo.steps const prepared = yield* modelRequests.prepare({
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agentInfo.permissions) context: loaded,
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id step: currentStep,
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 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 toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = [] const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
let needsContinuation = false let needsContinuation = false
@@ -209,7 +147,7 @@ const layer = Layer.effect(
// The selected catalog identity, not model.id: route-level ids are provider API // 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 ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
model: resolved.ref, model: resolved.ref,
providerMetadataKey, providerMetadataKey: model.route.providerMetadataKey ?? model.provider,
snapshot: startSnapshot, snapshot: startSnapshot,
assistantMessageID, assistantMessageID,
}) })
@@ -219,7 +157,7 @@ const layer = Layer.effect(
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(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)) const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
let overflowFailure: ProviderErrorEvent | undefined let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(hookedRequest).pipe( const providerStream = llm.stream(prepared.request).pipe(
Stream.runForEach((event) => Stream.runForEach((event) =>
Effect.gen(function* () { Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return if (overflowFailure || publisher.hasProviderError()) return
@@ -231,15 +169,9 @@ const layer = Layer.effect(
} }
yield* publish(event) yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization || (availableTools.has(event.name) && !advertisedTools.has(event.name))) { const tool = prepared.resolveToolCall(event.name)
yield* serialized( if (tool.type === "reject") {
publisher.failUnsettledTools({ yield* serialized(publisher.failUnsettledTools(tool.error))
type: "tool.execution",
message: toolMaterialization
? `Tool is not available for this request: ${event.name}`
: "Tools are disabled after the maximum agent steps",
}),
)
return return
} }
needsContinuation = true needsContinuation = true
@@ -247,7 +179,7 @@ const layer = Layer.effect(
ownedToolFibers.push( ownedToolFibers.push(
yield* Effect.uninterruptibleMask((restore) => yield* Effect.uninterruptibleMask((restore) =>
restore( restore(
toolMaterialization.settle({ tool.settle({
sessionID: session.id, sessionID: session.id,
agent: agent.id, agent: agent.id,
messageID: assistantMessageID, messageID: assistantMessageID,
@@ -337,10 +269,7 @@ const layer = Layer.effect(
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
if (llmFailure && !publisher.hasProviderError()) { if (llmFailure && !publisher.hasProviderError()) {
const error = toSessionError(llmFailure) const error = toSessionError(llmFailure)
if ( if (SessionRunnerRetry.isRetryable(llmFailure) && !publisher.hasRetryEvidence()) {
SessionRunnerRetry.isRetryable(llmFailure) &&
!publisher.hasRetryEvidence()
) {
return yield* new SessionRunnerRetry.RetryableFailure({ return yield* new SessionRunnerRetry.RetryableFailure({
cause: llmFailure, cause: llmFailure,
assistantMessageID: yield* publisher.startAssistant(), assistantMessageID: yield* publisher.startAssistant(),
@@ -570,9 +499,8 @@ export const node = makeLocationNode({
deps: [ deps: [
EventV2.node, EventV2.node,
llmClient, llmClient,
ToolRegistry.node,
PluginHooks.node,
SessionContext.node, SessionContext.node,
SessionModelRequest.node,
SessionStore.node, SessionStore.node,
SessionCompaction.node, SessionCompaction.node,
SessionTitle.node, SessionTitle.node,
+9 -9
View File
@@ -39,13 +39,13 @@ session.prompt
-> SessionAdmission -> SessionAdmission
-> SessionExecution -> SessionExecution
-> SessionContext.snapshot -> SessionContext.snapshot
-> SessionRequest.prepare -> SessionModelRequest.prepare
-> LLMClient.stream -> LLMClient.stream
-> SessionSettlement -> SessionSettlement
session.generate session.generate
-> SessionContext.snapshot -> SessionContext.snapshot
-> SessionRequest.prepare -> SessionModelRequest.prepare
-> LLMClient.generate -> LLMClient.generate
-> return text -> return text
``` ```
@@ -54,7 +54,7 @@ The modules have distinct jobs:
- `SessionAdmission` records and promotes durable input. - `SessionAdmission` records and promotes durable input.
- `SessionContext` resolves a read-only, internally consistent view of what the selected agent would see. - `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. - `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. - `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. - `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. The first extraction should be one internal Location-scoped module with a small interface. Names are provisional; behavior is not.
```ts ```ts
interface SessionRequest { interface SessionModelRequest {
readonly prepare: (input: { readonly prepare: (input: {
snapshot: SessionContext.Snapshot snapshot: SessionContext.Snapshot
operation: { type: "step"; current: number; maximum?: number } | { type: "generate"; prompt: Message.User } operation: { type: "step"; current: number; maximum?: number } | { type: "generate"; prompt: Message.User }
}) => Effect<PreparedSessionRequest, SessionRequestError> }) => Effect<PreparedSessionModelRequest, SessionModelRequestError>
} }
``` ```
```ts ```ts
type PreparedSessionRequest = { type PreparedSessionModelRequest = {
request: LLM.Request request: LLM.Request
snapshot: SessionContext.Snapshot snapshot: SessionContext.Snapshot
executableTools?: MaterializedTools 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: The existing Session context hook should run because it participates in normal request preparation. Its event should eventually identify the operation:
```ts ```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. 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`. 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. 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.