Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/core/src/session/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
119 changes: 119 additions & 0 deletions packages/core/src/session/model-request.ts
Original file line number Diff line number Diff line change
@@ -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],
})
100 changes: 14 additions & 86 deletions packages/core/src/session/runner/llm.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<void, ToolOutputStore.Error>()
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
let needsContinuation = false
Expand All @@ -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,
})
Expand All @@ -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
Expand All @@ -231,23 +169,17 @@ 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
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
ownedToolFibers.push(
yield* Effect.uninterruptibleMask((restore) =>
restore(
toolMaterialization.settle({
tool.settle({
sessionID: session.id,
agent: agent.id,
messageID: assistantMessageID,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading