diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index d1e829fa14f0..9bdf354731a0 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -34,13 +34,19 @@ export interface Loaded { readonly messages: ReadonlyArray } +/** + * 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 - /** 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 } +/** Location-scoped model-context loader for durable Session Steps. */ export class Service extends Context.Service()("@opencode/v2/SessionContext") {} const layer = Layer.effect( diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts new file mode 100644 index 000000000000..936c30591a91 --- /dev/null +++ b/packages/core/src/session/model-request.ts @@ -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 +} + +/** Location-scoped outbound model-request preparation. */ +export class Service extends Context.Service()("@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], +}) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index a3e2a928d5c8..63dc59dbddfe 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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 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 prepared = yield* modelRequests.prepare({ + context: loaded, + step: currentStep, }) - const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name)) const toolFibers = yield* FiberSet.make() const ownedToolFibers: Array> = [] 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 = (effect: Effect.Effect) => 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, diff --git a/plans/session-generate.md b/plans/session-generate.md index 3cbaaaf7abbd..e7bb955b009b 100644 --- a/plans/session-generate.md +++ b/plans/session-generate.md @@ -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 + }) => Effect } ``` ```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.