From ba8b2ac56595ffad772aa39758d23c595840c132 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Jul 2026 22:52:13 -0400 Subject: [PATCH 1/5] refactor(core): extract session request preparation --- packages/core/src/session/request.ts | 121 ++++++++++++++++++++++++ packages/core/src/session/runner/llm.ts | 95 +++---------------- 2 files changed, 135 insertions(+), 81 deletions(-) create mode 100644 packages/core/src/session/request.ts diff --git a/packages/core/src/session/request.ts b/packages/core/src/session/request.ts new file mode 100644 index 000000000000..7e958ac6df0e --- /dev/null +++ b/packages/core/src/session/request.ts @@ -0,0 +1,121 @@ +export * as SessionRequest from "./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 ResolvedTool = + | { readonly type: "reject"; readonly error: SessionError.Error } + | { readonly type: "settle"; readonly settle: ToolRegistry.Materialization["settle"] } + +interface Prepared { + readonly request: LLMRequest + readonly retryAllowed: boolean + readonly resolveTool: (name: string) => ResolvedTool +} + +interface PrepareInput { + readonly context: SessionContext.Loaded + readonly step: number +} + +export interface Interface { + readonly prepare: (input: PrepareInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/SessionRequest") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const hooks = yield* PluginHooks.Service + const registry = yield* ToolRegistry.Service + + const prepare = Effect.fn("SessionRequest.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 is string => part !== undefined && 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 toolsByName = new Map((executableTools?.definitions ?? []).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( + Array.from(toolsByName.values(), (tool) => [ + tool.name, + { description: tool.description, input: { ...tool.inputSchema } }, + ]), + ), + }) + // Leave hook-removed definitions in the map so calls to them can be rejected before settlement. + const hookedTools = Object.entries(contextEvent.tools).reduce>( + (result, [name, tool]) => { + const registered = toolsByName.get(name) + if (!registered) return result + toolsByName.delete(name) + result.push(Object.assign({}, registered, { description: tool.description, inputSchema: tool.input })) + return result + }, + [], + ) + 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 resolveTool = (name: string): ResolvedTool => { + if (!executableTools) + return { + type: "reject", + error: { type: "tool.execution", message: "Tools are disabled after the maximum agent steps" }, + } + if (toolsByName.has(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, + retryAllowed: !stepLimitReached, + resolveTool, + } + }) + + 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..b4a24d8417d6 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 { SessionRequest } from "../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 @@ -78,10 +63,9 @@ const layer = Layer.effect( 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 requests = yield* SessionRequest.Service const snapshots = yield* Snapshot.Service const db = (yield* Database.Service).db const compaction = yield* SessionCompaction.Service @@ -146,59 +130,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* requests.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() const ownedToolFibers: Array> = [] let needsContinuation = false @@ -209,7 +152,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 +162,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 +174,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.resolveTool(event.name) + if (tool.type === "reject") { + yield* serialized(publisher.failUnsettledTools(tool.error)) return } needsContinuation = true @@ -247,7 +184,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 +274,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 +504,8 @@ export const node = makeLocationNode({ deps: [ EventV2.node, llmClient, - ToolRegistry.node, - PluginHooks.node, SessionContext.node, + SessionRequest.node, SessionStore.node, SessionCompaction.node, SessionTitle.node, From 2ccf4ccb263c21a62565f84bbdce02c6e329d280 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Jul 2026 09:57:15 -0400 Subject: [PATCH 2/5] docs(core): clarify session service boundaries --- packages/core/src/session/context.ts | 14 ++++++++++++-- packages/core/src/session/request.ts | 13 +++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index d1e829fa14f0..fdec20f00896 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -34,13 +34,23 @@ export interface Loaded { readonly messages: ReadonlyArray } +/** + * Loads the model-facing Session inputs on either side of the runner's durable + * pre-request work. + * + * `select` runs before instruction synchronization and pending-input promotion + * so those operations use one selected Session, agent, and instruction set. + * `load` runs afterward and adds the selected model and resulting active + * history. This module does not prepare or execute a provider request. + */ export interface Interface { - /** Resolves the Session, selected agent, and current instruction sources before durable Step preparation. */ + /** Resolves the Session, selected agent, and current instruction sources. */ readonly select: (sessionID: SessionSchema.ID) => Effect.Effect - /** Loads the selected model and active history after instruction sync and pending-input promotion. */ + /** Adds the selected model and active history after the durable pre-request work. */ 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/request.ts b/packages/core/src/session/request.ts index 7e958ac6df0e..28f63b9b258f 100644 --- a/packages/core/src/session/request.ts +++ b/packages/core/src/session/request.ts @@ -27,10 +27,23 @@ interface PrepareInput { readonly step: number } +/** + * Prepares the canonical provider request for one Session Step. + * + * Preparation assembles system and history messages, applies Session headers + * and prompt-cache identity, materializes permitted tools, enforces the Step + * limit, and runs Session context hooks. The result keeps the request paired + * with its retry and tool-settlement capabilities. + * + * This module does not call the provider, execute tools, publish Session + * events, or mutate Session history. + */ export interface Interface { + /** Produces one provider-ready request and its matching execution capabilities. */ readonly prepare: (input: PrepareInput) => Effect.Effect } +/** Location-scoped request preparation for Session model calls. */ export class Service extends Context.Service()("@opencode/v2/SessionRequest") {} const layer = Layer.effect( From 3c2df5947b5fd7fa3de6c1e672fab1d4b28561bc Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Jul 2026 09:57:57 -0400 Subject: [PATCH 3/5] docs(core): describe session service contracts --- packages/core/src/session/context.ts | 12 +++++------- packages/core/src/session/request.ts | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index fdec20f00896..9a6fba178413 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -35,18 +35,16 @@ export interface Loaded { } /** - * Loads the model-facing Session inputs on either side of the runner's durable - * pre-request work. + * Resolves the model-facing inputs for a Session. * - * `select` runs before instruction synchronization and pending-input promotion - * so those operations use one selected Session, agent, and instruction set. - * `load` runs afterward and adds the selected model and resulting active - * history. This module does not prepare or execute a provider request. + * `select` fixes the Session, agent, and instruction sources for subsequent + * work. `load` adds the selected model and active history for that selection. + * This module does not prepare or execute a provider request. */ export interface Interface { /** Resolves the Session, selected agent, and current instruction sources. */ readonly select: (sessionID: SessionSchema.ID) => Effect.Effect - /** Adds the selected model and active history after the durable pre-request work. */ + /** Adds the selected model and active history. */ readonly load: (selection: Selection) => Effect.Effect } diff --git a/packages/core/src/session/request.ts b/packages/core/src/session/request.ts index 28f63b9b258f..fe0c01b146d8 100644 --- a/packages/core/src/session/request.ts +++ b/packages/core/src/session/request.ts @@ -81,7 +81,7 @@ const layer = Layer.effect( ]), ), }) - // Leave hook-removed definitions in the map so calls to them can be rejected before settlement. + // Tools removed by context hooks remain non-executable for this request. const hookedTools = Object.entries(contextEvent.tools).reduce>( (result, [name, tool]) => { const registered = toolsByName.get(name) From d6d57d85d6bd3fb383bdf074c1ceb6d12edb9e5a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Jul 2026 10:01:32 -0400 Subject: [PATCH 4/5] refactor(core): clarify session model requests --- packages/core/src/session/context.ts | 10 ++---- .../session/{request.ts => model-request.ts} | 31 +++++++------------ packages/core/src/session/runner/llm.ts | 15 +++------ plans/session-generate.md | 18 +++++------ 4 files changed, 29 insertions(+), 45 deletions(-) rename packages/core/src/session/{request.ts => model-request.ts} (81%) diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index 9a6fba178413..367a0feee995 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -35,16 +35,12 @@ export interface Loaded { } /** - * Resolves the model-facing inputs for a Session. - * - * `select` fixes the Session, agent, and instruction sources for subsequent - * work. `load` adds the selected model and active history for that selection. - * This module does not prepare or execute a provider request. + * 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. */ readonly select: (sessionID: SessionSchema.ID) => Effect.Effect - /** Adds the selected model and active history. */ readonly load: (selection: Selection) => Effect.Effect } diff --git a/packages/core/src/session/request.ts b/packages/core/src/session/model-request.ts similarity index 81% rename from packages/core/src/session/request.ts rename to packages/core/src/session/model-request.ts index fe0c01b146d8..7f7e1f1ffc7c 100644 --- a/packages/core/src/session/request.ts +++ b/packages/core/src/session/model-request.ts @@ -1,4 +1,4 @@ -export * as SessionRequest from "./request" +export * as SessionModelRequest from "./model-request" import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai" import { SessionError } from "@opencode-ai/schema/session-error" @@ -12,14 +12,14 @@ import { MAX_STEPS_PROMPT } from "./runner/max-steps" import PROMPT_DEFAULT from "./runner/prompt/base.txt" import { toLLMMessages } from "./runner/to-llm-message" -type ResolvedTool = +type ToolCallResolution = | { readonly type: "reject"; readonly error: SessionError.Error } | { readonly type: "settle"; readonly settle: ToolRegistry.Materialization["settle"] } interface Prepared { readonly request: LLMRequest readonly retryAllowed: boolean - readonly resolveTool: (name: string) => ResolvedTool + readonly resolveToolCall: (name: string) => ToolCallResolution } interface PrepareInput { @@ -28,23 +28,16 @@ interface PrepareInput { } /** - * Prepares the canonical provider request for one Session Step. - * - * Preparation assembles system and history messages, applies Session headers - * and prompt-cache identity, materializes permitted tools, enforces the Step - * limit, and runs Session context hooks. The result keeps the request paired - * with its retry and tool-settlement capabilities. - * - * This module does not call the provider, execute tools, publish Session - * events, or mutate Session history. + * Builds an outbound model request and captures the retry and tool-call + * capabilities that must remain paired with it. It does not execute the + * request or mutate Session state. */ export interface Interface { - /** Produces one provider-ready request and its matching execution capabilities. */ readonly prepare: (input: PrepareInput) => Effect.Effect } -/** Location-scoped request preparation for Session model calls. */ -export class Service extends Context.Service()("@opencode/v2/SessionRequest") {} +/** Location-scoped outbound model-request preparation. */ +export class Service extends Context.Service()("@opencode/v2/SessionModelRequest") {} const layer = Layer.effect( Service, @@ -52,7 +45,7 @@ const layer = Layer.effect( const hooks = yield* PluginHooks.Service const registry = yield* ToolRegistry.Service - const prepare = Effect.fn("SessionRequest.prepare")(function* (input: PrepareInput) { + const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) { const session = input.context.session const agent = input.context.agent const resolved = input.context.model @@ -62,7 +55,7 @@ const layer = Layer.effect( 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 is string => part !== undefined && part.length > 0) + .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 @@ -103,7 +96,7 @@ const layer = Layer.effect( tools: hookedTools, toolChoice: stepLimitReached ? "none" : undefined, }) - const resolveTool = (name: string): ResolvedTool => { + const resolveToolCall = (name: string): ToolCallResolution => { if (!executableTools) return { type: "reject", @@ -119,7 +112,7 @@ const layer = Layer.effect( return { request, retryAllowed: !stepLimitReached, - resolveTool, + resolveToolCall, } }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index b4a24d8417d6..63dc59dbddfe 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -15,7 +15,7 @@ import { SessionCompaction } from "../compaction" import { SessionContext } from "../context" import { SessionEvent } from "../event" import { SessionPending } from "../pending" -import { SessionRequest } from "../request" +import { SessionModelRequest } from "../model-request" import { SessionMessage } from "../message" import { SessionSchema } from "../schema" import { SessionStore } from "../store" @@ -53,11 +53,6 @@ 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* () { @@ -65,7 +60,7 @@ const layer = Layer.effect( const llm = yield* LLMClient.Service const store = yield* SessionStore.Service const context = yield* SessionContext.Service - const requests = yield* SessionRequest.Service + const modelRequests = yield* SessionModelRequest.Service const snapshots = yield* Snapshot.Service const db = (yield* Database.Service).db const compaction = yield* SessionCompaction.Service @@ -138,7 +133,7 @@ const layer = Layer.effect( if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const return yield* new StepFailedError({ error: compacted.error }) } - const prepared = yield* requests.prepare({ + const prepared = yield* modelRequests.prepare({ context: loaded, step: currentStep, }) @@ -174,7 +169,7 @@ const layer = Layer.effect( } yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return - const tool = prepared.resolveTool(event.name) + const tool = prepared.resolveToolCall(event.name) if (tool.type === "reject") { yield* serialized(publisher.failUnsettledTools(tool.error)) return @@ -505,7 +500,7 @@ export const node = makeLocationNode({ EventV2.node, llmClient, SessionContext.node, - SessionRequest.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. From 8df62f27245c5d0a3086fcd624dd3b910d03852d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Jul 2026 10:47:41 -0400 Subject: [PATCH 5/5] refactor(core): simplify session model preparation --- packages/core/src/session/context.ts | 2 ++ packages/core/src/session/model-request.ts | 36 +++++++++------------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index 367a0feee995..9bdf354731a0 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -40,7 +40,9 @@ export interface Loaded { * that selection. This module does not build or execute the model request. */ export interface Interface { + /** Selects the Session, agent, and instruction sources used by subsequent work. */ readonly select: (sessionID: SessionSchema.ID) => Effect.Effect + /** Resolves the model and active history for that selection. */ readonly load: (selection: Selection) => Effect.Effect } diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 7f7e1f1ffc7c..936c30591a91 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -18,7 +18,6 @@ type ToolCallResolution = interface Prepared { readonly request: LLMRequest - readonly retryAllowed: boolean readonly resolveToolCall: (name: string) => ToolCallResolution } @@ -28,11 +27,12 @@ interface PrepareInput { } /** - * Builds an outbound model request and captures the retry and tool-call - * capabilities that must remain paired with it. It does not execute the - * request or mutate Session state. + * 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 } @@ -59,7 +59,8 @@ const layer = Layer.effect( .map(SystemPart.make) const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey) const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history - const toolsByName = new Map((executableTools?.definitions ?? []).map((tool) => [tool.name, tool])) + 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, @@ -68,23 +69,15 @@ const layer = Layer.effect( system, messages, tools: Object.fromEntries( - Array.from(toolsByName.values(), (tool) => [ - tool.name, - { description: tool.description, input: { ...tool.inputSchema } }, - ]), + toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]), ), }) - // Tools removed by context hooks remain non-executable for this request. - const hookedTools = Object.entries(contextEvent.tools).reduce>( - (result, [name, tool]) => { - const registered = toolsByName.get(name) - if (!registered) return result - toolsByName.delete(name) - result.push(Object.assign({}, registered, { description: tool.description, inputSchema: tool.input })) - return result - }, - [], - ) + 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: { @@ -102,7 +95,7 @@ const layer = Layer.effect( type: "reject", error: { type: "tool.execution", message: "Tools are disabled after the maximum agent steps" }, } - if (toolsByName.has(name)) + 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}` }, @@ -111,7 +104,6 @@ const layer = Layer.effect( } return { request, - retryAllowed: !stepLimitReached, resolveToolCall, } })