diff --git a/packages/core/src/session/request.ts b/packages/core/src/session/request.ts new file mode 100644 index 0000000000..7e958ac6df --- /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 b8f86f9d38..1bb98cac57 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,11 +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() && - (agentInfo.steps === undefined || currentStep < agentInfo.steps) - ) { + if (SessionRunnerRetry.isRetryable(llmFailure) && !publisher.hasRetryEvidence() && prepared.retryAllowed) { return yield* new SessionRunnerRetry.RetryableFailure({ cause: llmFailure, assistantMessageID: yield* publisher.startAssistant(), @@ -571,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,