mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53a93aa494 | |||
| 153d7a9719 |
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@opencode-ai/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Apply shared Session model-request preparation to transient generation.
|
||||||
@@ -1,85 +1,54 @@
|
|||||||
export * as SessionGenerateNode from "./generate-node.js"
|
export * as SessionGenerateNode from "./generate-node.js"
|
||||||
|
|
||||||
import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai"
|
import { LLMClient, Message } from "@opencode-ai/ai"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { Database } from "../database/database.js"
|
import { Database } from "../database/database.js"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { App } from "../app.js"
|
|
||||||
import { llmClient } from "../effect/app-node-platform.js"
|
import { llmClient } from "../effect/app-node-platform.js"
|
||||||
import { PluginHooks } from "../plugin/hooks.js"
|
|
||||||
import { SessionContext } from "./context.js"
|
import { SessionContext } from "./context.js"
|
||||||
import { SessionGenerate } from "./generate.js"
|
import { SessionGenerate } from "./generate.js"
|
||||||
import { SessionHistory } from "./history.js"
|
import { SessionHistory } from "./history.js"
|
||||||
import { SessionModelHeaders } from "./model-headers.js"
|
import { SessionModelRequest } from "./model-request.js"
|
||||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
|
||||||
import { SessionRunnerModel } from "./runner/model.js"
|
import { SessionRunnerModel } from "./runner/model.js"
|
||||||
import { SessionSystemPrompt } from "./system-prompt.js"
|
|
||||||
import { toLLMMessages } from "./runner/to-llm-message.js"
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
SessionGenerate.Service,
|
SessionGenerate.Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const context = yield* SessionContext.Service
|
const context = yield* SessionContext.Service
|
||||||
const database = yield* Database.Service
|
const database = yield* Database.Service
|
||||||
const hooks = yield* PluginHooks.Service
|
|
||||||
const llm = yield* LLMClient.Service
|
const llm = yield* LLMClient.Service
|
||||||
const models = yield* SessionRunnerModel.Service
|
const models = yield* SessionRunnerModel.Service
|
||||||
const app = yield* App.Metadata
|
const modelRequests = yield* SessionModelRequest.Service
|
||||||
|
|
||||||
return SessionGenerate.Service.of({
|
return SessionGenerate.Service.of({
|
||||||
generate: Effect.fn("SessionGenerate.generate")(function* (input) {
|
generate: Effect.fn("SessionGenerate.generate")(function* (input) {
|
||||||
const selection = yield* context.select(input.sessionID)
|
const selection = yield* context.select(input.sessionID)
|
||||||
const model = yield* models.resolve(selection.session)
|
const model = yield* models.resolve(selection.session)
|
||||||
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
|
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
|
||||||
const providerMetadataKey = model.model.route.providerMetadataKey ?? model.model.provider
|
const transcript = SessionModelRequest.baseTranscript({
|
||||||
const tools = selection.tools
|
agent: selection.agent.info,
|
||||||
const toolDefinitions = tools.definitions
|
model,
|
||||||
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
|
tools: selection.tools,
|
||||||
const contextEvent = yield* hooks.trigger("session", "context", {
|
initial: history.initial,
|
||||||
sessionID: selection.session.id,
|
messages: history.messages,
|
||||||
agent: selection.agent.id,
|
|
||||||
model: model.ref,
|
|
||||||
system: [
|
|
||||||
selection.agent.info.system
|
|
||||||
? selection.agent.info.system
|
|
||||||
: SessionSystemPrompt.make(toolDefinitions.map((tool) => tool.name)),
|
|
||||||
history.initial,
|
|
||||||
]
|
|
||||||
.filter((part) => part.length > 0)
|
|
||||||
.map(SystemPart.make),
|
|
||||||
messages: [
|
|
||||||
...toLLMMessages(history.messages, model.ref, providerMetadataKey),
|
|
||||||
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
|
|
||||||
Message.user(input.prompt),
|
|
||||||
],
|
|
||||||
tools: Object.fromEntries(
|
|
||||||
toolDefinitions.map((tool) => [
|
|
||||||
tool.name,
|
|
||||||
{ description: tool.description, input: { ...tool.inputSchema } },
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
|
const prepared = yield* modelRequests.prepare({
|
||||||
const registered = toolsByName.get(name)
|
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
|
||||||
return registered
|
transcript: {
|
||||||
? [Object.assign({}, registered, { description: tool.description, inputSchema: tool.input })]
|
system: transcript.system,
|
||||||
: []
|
messages: [
|
||||||
|
...transcript.messages,
|
||||||
|
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
|
||||||
|
Message.user(input.prompt),
|
||||||
|
],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
yield* Effect.logInfo("sending session generation request", {
|
yield* Effect.logInfo("sending session generation request", {
|
||||||
sessionID: selection.session.id,
|
sessionID: selection.session.id,
|
||||||
providerID: model.ref.providerID,
|
providerID: model.ref.providerID,
|
||||||
modelID: model.ref.id,
|
modelID: model.ref.id,
|
||||||
})
|
})
|
||||||
const response = yield* llm.generate(
|
const response = yield* llm.generate(prepared.request, prepared.options)
|
||||||
LLM.request({
|
|
||||||
model: model.model,
|
|
||||||
http: { headers: SessionModelHeaders.make(selection.session, app) },
|
|
||||||
promptCacheKey: SessionPromptCacheKey.make(selection.session.id),
|
|
||||||
system: contextEvent.system,
|
|
||||||
messages: contextEvent.messages,
|
|
||||||
tools: hookedTools,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
|
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
|
||||||
return response.text
|
return response.text
|
||||||
}),
|
}),
|
||||||
@@ -90,5 +59,5 @@ export const layer = Layer.effect(
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: SessionGenerate.Service,
|
service: SessionGenerate.Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, App.node, llmClient],
|
deps: [SessionContext.node, Database.node, SessionModelRequest.node, SessionRunnerModel.node, llmClient],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,15 +12,16 @@ import { Permission } from "../permission.js"
|
|||||||
import { PluginHooks } from "../plugin/hooks.js"
|
import { PluginHooks } from "../plugin/hooks.js"
|
||||||
import { QuestionTool } from "../tool/plugin/question.js"
|
import { QuestionTool } from "../tool/plugin/question.js"
|
||||||
import { Tool } from "../tool.js"
|
import { Tool } from "../tool.js"
|
||||||
import { SessionContext } from "./context.js"
|
|
||||||
import { SessionModelHeaders } from "./model-headers.js"
|
import { SessionModelHeaders } from "./model-headers.js"
|
||||||
import { SessionModelHttp } from "./model-http.js"
|
import { SessionModelHttp } from "./model-http.js"
|
||||||
import { SessionModelTransport } from "./model-transport.js"
|
import { SessionModelTransport } from "./model-transport.js"
|
||||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||||
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics.js"
|
import { SessionRunnerModel } from "./runner/model.js"
|
||||||
import { MAX_STEPS_PROMPT } from "./runner/max-steps.js"
|
import { SessionSchema } from "./schema.js"
|
||||||
import { SessionSystemPrompt } from "./system-prompt.js"
|
import { SessionSystemPrompt } from "./system-prompt.js"
|
||||||
import { toLLMMessages } from "./runner/to-llm-message.js"
|
import { toLLMMessages } from "./runner/to-llm-message.js"
|
||||||
|
import type { SessionMessage } from "./message.js"
|
||||||
|
import type { Agent } from "../agent.js"
|
||||||
|
|
||||||
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
|
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
|
||||||
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
|
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
|
||||||
@@ -47,20 +48,49 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
|
|||||||
interface Prepared {
|
interface Prepared {
|
||||||
readonly request: LLMRequest
|
readonly request: LLMRequest
|
||||||
readonly options: StreamOptions
|
readonly options: StreamOptions
|
||||||
/** False when Session HTTP hooks require the request to remain on HTTP. */
|
|
||||||
readonly webSocketEligible: boolean
|
|
||||||
/**
|
/**
|
||||||
* One request-scoped execution operation. Unknown, hook-removed, and
|
* One request-scoped execution operation. Unknown and hook-removed calls
|
||||||
* step-limit-violating calls fail individually through the same seam.
|
* fail individually through the same seam.
|
||||||
*/
|
*/
|
||||||
readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError>
|
readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError>
|
||||||
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */
|
|
||||||
readonly stepLimitReached: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PrepareInput {
|
interface PrepareInput {
|
||||||
readonly context: SessionContext.Loaded
|
readonly scope: {
|
||||||
readonly step: number
|
readonly session: SessionSchema.Info
|
||||||
|
readonly agentID: Agent.ID
|
||||||
|
readonly model: SessionRunnerModel.Resolved
|
||||||
|
readonly tools: Tool.Snapshot
|
||||||
|
}
|
||||||
|
readonly transcript: {
|
||||||
|
readonly system: Array<SystemPart>
|
||||||
|
readonly messages: Array<Message>
|
||||||
|
}
|
||||||
|
readonly toolChoice?: LLM.RequestInput["toolChoice"]
|
||||||
|
/** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */
|
||||||
|
readonly webSocket?: "session"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const baseTranscript = (input: {
|
||||||
|
readonly agent: Agent.Info
|
||||||
|
readonly model: SessionRunnerModel.Resolved
|
||||||
|
readonly tools: Tool.Snapshot
|
||||||
|
readonly initial: string
|
||||||
|
readonly messages: ReadonlyArray<SessionMessage.Info>
|
||||||
|
}) => {
|
||||||
|
const providerMetadataKey = input.model.model.route.providerMetadataKey ?? input.model.model.provider
|
||||||
|
return {
|
||||||
|
providerMetadataKey,
|
||||||
|
system: [
|
||||||
|
input.agent.system
|
||||||
|
? input.agent.system
|
||||||
|
: SessionSystemPrompt.make(input.tools.definitions.map((tool) => tool.name)),
|
||||||
|
input.initial,
|
||||||
|
]
|
||||||
|
.filter((part) => part.length > 0)
|
||||||
|
.map(SystemPart.make),
|
||||||
|
messages: toLLMMessages(input.messages, input.model.ref, providerMetadataKey),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mimeToModality = (mime: string) => {
|
const mimeToModality = (mime: string) => {
|
||||||
@@ -174,30 +204,11 @@ export const layer = Layer.effect(
|
|||||||
Config.withDefault(false),
|
Config.withDefault(false),
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
)
|
)
|
||||||
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
|
|
||||||
Config.withDefault(false),
|
|
||||||
Effect.orDie,
|
|
||||||
)
|
|
||||||
const promptCacheSnapshots = diagnostics ? new Map<string, PromptCacheDiagnostics.Snapshot>() : undefined
|
|
||||||
|
|
||||||
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
|
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
|
||||||
const session = input.context.session
|
const session = input.scope.session
|
||||||
const agent = input.context.agent
|
const resolved = input.scope.model
|
||||||
const resolved = input.context.model
|
|
||||||
const model = resolved.model
|
const model = resolved.model
|
||||||
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
|
const tools = input.scope.tools
|
||||||
const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps
|
|
||||||
// The final Step keeps definitions available to protocols with native "none",
|
|
||||||
// preserving their prompt cache prefix. Calls are still rejected at execution.
|
|
||||||
const tools = input.context.tools
|
|
||||||
const system = [
|
|
||||||
agent.info.system ? agent.info.system : SessionSystemPrompt.make(tools.definitions.map((tool) => tool.name)),
|
|
||||||
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 registry = new Map(tools.definitions.map((tool) => [tool.name, tool]))
|
const registry = new Map(tools.definitions.map((tool) => [tool.name, tool]))
|
||||||
// The definition objects we hand to hooks, mapped back to their tools. Hooks rename a
|
// The definition objects we hand to hooks, mapped back to their tools. Hooks rename a
|
||||||
// tool by moving its definition to a new key; recognizing the object recovers the tool.
|
// tool by moving its definition to a new key; recognizing the object recovers the tool.
|
||||||
@@ -209,10 +220,10 @@ export const layer = Layer.effect(
|
|||||||
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
|
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
|
||||||
const context = yield* hooks.trigger("session", "context", {
|
const context = yield* hooks.trigger("session", "context", {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
agent: agent.id,
|
agent: input.scope.agentID,
|
||||||
model: resolved.ref,
|
model: resolved.ref,
|
||||||
system,
|
system: input.transcript.system,
|
||||||
messages,
|
messages: input.transcript.messages,
|
||||||
tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])),
|
tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])),
|
||||||
})
|
})
|
||||||
// Match each surviving entry back to its tool, by recognizing a moved definition or
|
// Match each surviving entry back to its tool, by recognizing a moved definition or
|
||||||
@@ -235,7 +246,7 @@ export const layer = Layer.effect(
|
|||||||
system: context.system,
|
system: context.system,
|
||||||
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
|
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
|
||||||
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||||
toolChoice: stepLimitReached ? "none" : undefined,
|
toolChoice: input.toolChoice,
|
||||||
})
|
})
|
||||||
const webSocketEligible =
|
const webSocketEligible =
|
||||||
!(yield* hooks.has("session", "http.request")) && !(yield* hooks.has("session", "http.response"))
|
!(yield* hooks.has("session", "http.request")) && !(yield* hooks.has("session", "http.response"))
|
||||||
@@ -243,37 +254,20 @@ export const layer = Layer.effect(
|
|||||||
? undefined
|
? undefined
|
||||||
: SessionModelHttp.middleware(hooks, {
|
: SessionModelHttp.middleware(hooks, {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
agent: agent.id,
|
agent: input.scope.agentID,
|
||||||
model: resolved.ref,
|
model: resolved.ref,
|
||||||
})
|
})
|
||||||
const options: StreamOptions = {
|
const options: StreamOptions = {
|
||||||
...(http ? { http } : {}),
|
...(http ? { http } : {}),
|
||||||
...(webSocket &&
|
...(input.webSocket === "session" &&
|
||||||
|
webSocket &&
|
||||||
webSocketEligible &&
|
webSocketEligible &&
|
||||||
resolved.ref.providerID === Provider.ID.openai &&
|
resolved.ref.providerID === Provider.ID.openai &&
|
||||||
model.route.id === "openai-responses"
|
model.route.id === "openai-responses"
|
||||||
? { webSocket: transport.bind(session.id) }
|
? { webSocket: transport.bind(session.id) }
|
||||||
: {}),
|
: {}),
|
||||||
}
|
}
|
||||||
if (promptCacheSnapshots) {
|
|
||||||
const current = PromptCacheDiagnostics.snapshot(request)
|
|
||||||
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(session.id), current)
|
|
||||||
promptCacheSnapshots.delete(session.id)
|
|
||||||
promptCacheSnapshots.set(session.id, current)
|
|
||||||
const oldest = promptCacheSnapshots.keys().next().value
|
|
||||||
if (promptCacheSnapshots.size > 100 && oldest !== undefined) promptCacheSnapshots.delete(oldest)
|
|
||||||
yield* Effect.logInfo("prompt cache prefix").pipe(
|
|
||||||
Effect.annotateLogs({
|
|
||||||
sessionID: session.id,
|
|
||||||
toolCount: current.tools.length,
|
|
||||||
systemParts: current.system.length,
|
|
||||||
messageCount: current.messages.length,
|
|
||||||
...comparison,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const executeTool: Prepared["executeTool"] = (input) => {
|
const executeTool: Prepared["executeTool"] = (input) => {
|
||||||
if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
|
||||||
const tool = hooked.get(input.call.name)
|
const tool = hooked.get(input.call.name)
|
||||||
// A registered tool absent from the hooked set was removed or renamed by a hook.
|
// A registered tool absent from the hooked set was removed or renamed by a hook.
|
||||||
if (!tool && registry.has(input.call.name))
|
if (!tool && registry.has(input.call.name))
|
||||||
@@ -285,9 +279,7 @@ export const layer = Layer.effect(
|
|||||||
return {
|
return {
|
||||||
request,
|
request,
|
||||||
options,
|
options,
|
||||||
webSocketEligible,
|
|
||||||
executeTool,
|
executeTool,
|
||||||
stepLimitReached,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import {
|
|||||||
LLMClient,
|
LLMClient,
|
||||||
AIError,
|
AIError,
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
|
Message,
|
||||||
isContextOverflowFailure,
|
isContextOverflowFailure,
|
||||||
type ProviderErrorEvent,
|
type ProviderErrorEvent,
|
||||||
type ToolCall,
|
type ToolCall,
|
||||||
} from "@opencode-ai/ai"
|
} from "@opencode-ai/ai"
|
||||||
import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
|
import { Cause, Config, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect"
|
||||||
import { Database } from "../../database/database.js"
|
import { Database } from "../../database/database.js"
|
||||||
import { Bus } from "../../bus.js"
|
import { Bus } from "../../bus.js"
|
||||||
import { Permission } from "../../permission.js"
|
import { Permission } from "../../permission.js"
|
||||||
@@ -34,6 +35,9 @@ import { toSessionError } from "../to-session-error.js"
|
|||||||
import { SessionRunnerRetry } from "./retry.js"
|
import { SessionRunnerRetry } from "./retry.js"
|
||||||
import { SessionUsage } from "../usage.js"
|
import { SessionUsage } from "../usage.js"
|
||||||
import { ToolOutput } from "../../tool-output.js"
|
import { ToolOutput } from "../../tool-output.js"
|
||||||
|
import { Tool } from "../../tool.js"
|
||||||
|
import { PromptCacheDiagnostics } from "../prompt-cache-diagnostics.js"
|
||||||
|
import { MAX_STEPS_PROMPT } from "./max-steps.js"
|
||||||
|
|
||||||
/** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */
|
/** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */
|
||||||
type CallOutcome = Data.TaggedEnum<{
|
type CallOutcome = Data.TaggedEnum<{
|
||||||
@@ -116,6 +120,32 @@ const layer = Layer.effect(
|
|||||||
const compaction = yield* SessionCompaction.Service
|
const compaction = yield* SessionCompaction.Service
|
||||||
const title = yield* SessionTitle.Service
|
const title = yield* SessionTitle.Service
|
||||||
const toolOutput = yield* ToolOutput.Service
|
const toolOutput = yield* ToolOutput.Service
|
||||||
|
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
|
||||||
|
Config.withDefault(false),
|
||||||
|
Effect.orDie,
|
||||||
|
)
|
||||||
|
const promptCacheSnapshots = diagnostics ? new Map<string, PromptCacheDiagnostics.Snapshot>() : undefined
|
||||||
|
const diagnosePromptCache = Effect.fn("SessionRunner.diagnosePromptCache")(function* (
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
request: Parameters<typeof PromptCacheDiagnostics.snapshot>[0],
|
||||||
|
) {
|
||||||
|
if (!promptCacheSnapshots) return
|
||||||
|
const current = PromptCacheDiagnostics.snapshot(request)
|
||||||
|
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(sessionID), current)
|
||||||
|
promptCacheSnapshots.delete(sessionID)
|
||||||
|
promptCacheSnapshots.set(sessionID, current)
|
||||||
|
const oldest = promptCacheSnapshots.keys().next().value
|
||||||
|
if (promptCacheSnapshots.size > 100 && oldest !== undefined) promptCacheSnapshots.delete(oldest)
|
||||||
|
yield* Effect.logInfo("prompt cache prefix").pipe(
|
||||||
|
Effect.annotateLogs({
|
||||||
|
sessionID,
|
||||||
|
toolCount: current.tools.length,
|
||||||
|
systemParts: current.system.length,
|
||||||
|
messageCount: current.messages.length,
|
||||||
|
...comparison,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
// Title generation starts once input is visible and must not delay model execution.
|
// Title generation starts once input is visible and must not delay model execution.
|
||||||
// The in-flight set coalesces overlapping prompts while title presence records success durably.
|
// The in-flight set coalesces overlapping prompts while title presence records success durably.
|
||||||
const titlesRunning = new Set<SessionSchema.ID>()
|
const titlesRunning = new Set<SessionSchema.ID>()
|
||||||
@@ -278,10 +308,32 @@ const layer = Layer.effect(
|
|||||||
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
|
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
|
||||||
return yield* new StepFailedError({ error: compacted.error })
|
return yield* new StepFailedError({ error: compacted.error })
|
||||||
}
|
}
|
||||||
const prepared = yield* modelRequests.prepare({
|
const stepLimitReached = agent.info.steps !== undefined && currentStep >= agent.info.steps
|
||||||
context: loaded,
|
const transcript = SessionModelRequest.baseTranscript({
|
||||||
step: currentStep,
|
agent: agent.info,
|
||||||
|
model: resolved,
|
||||||
|
tools: loaded.tools,
|
||||||
|
initial: loaded.initial,
|
||||||
|
messages: loaded.messages,
|
||||||
})
|
})
|
||||||
|
const prepared = yield* modelRequests.prepare({
|
||||||
|
scope: { session, agentID: agent.id, model: resolved, tools: loaded.tools },
|
||||||
|
transcript: {
|
||||||
|
system: transcript.system,
|
||||||
|
messages: stepLimitReached
|
||||||
|
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
|
||||||
|
: transcript.messages,
|
||||||
|
},
|
||||||
|
// The final Step keeps definitions available to protocols with native "none",
|
||||||
|
// preserving their prompt cache prefix. Calls are still rejected at execution.
|
||||||
|
toolChoice: stepLimitReached ? "none" : undefined,
|
||||||
|
webSocket: "session",
|
||||||
|
})
|
||||||
|
yield* diagnosePromptCache(session.id, prepared.request)
|
||||||
|
const executeTool = (input: Parameters<typeof prepared.executeTool>[0]) => {
|
||||||
|
if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
||||||
|
return prepared.executeTool(input)
|
||||||
|
}
|
||||||
// Every local tool call forked here is owned until it reaches one durable settlement.
|
// Every local tool call forked here is owned until it reaches one durable settlement.
|
||||||
const toolRuns: Array<{
|
const toolRuns: Array<{
|
||||||
readonly call: ToolCall
|
readonly call: ToolCall
|
||||||
@@ -295,7 +347,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: model.route.providerMetadataKey ?? model.provider,
|
providerMetadataKey: transcript.providerMetadataKey,
|
||||||
snapshot: startSnapshot,
|
snapshot: startSnapshot,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
})
|
})
|
||||||
@@ -356,7 +408,7 @@ const layer = Layer.effect(
|
|||||||
call: event,
|
call: event,
|
||||||
fiber: yield* Effect.uninterruptibleMask((restore) =>
|
fiber: yield* Effect.uninterruptibleMask((restore) =>
|
||||||
restore(
|
restore(
|
||||||
prepared.executeTool({
|
executeTool({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
messageID: assistantMessageID,
|
messageID: assistantMessageID,
|
||||||
@@ -504,7 +556,7 @@ const layer = Layer.effect(
|
|||||||
// A local call or malformed tool input requires another model step, unless
|
// A local call or malformed tool input requires another model step, unless
|
||||||
// this step already exhausted the agent's allowance.
|
// this step already exhausted the agent's allowance.
|
||||||
needsContinuation:
|
needsContinuation:
|
||||||
!prepared.stepLimitReached &&
|
!stepLimitReached &&
|
||||||
record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)),
|
record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)),
|
||||||
step: currentStep,
|
step: currentStep,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
} from "@opencode-ai/ai"
|
} from "@opencode-ai/ai"
|
||||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||||
|
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||||
import { Agent } from "@opencode-ai/core/agent"
|
import { Agent } from "@opencode-ai/core/agent"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
@@ -51,15 +52,17 @@ import { Effect, Layer, Schema, Stream } from "effect"
|
|||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const requests: LLMRequest[] = []
|
const requests: LLMRequest[] = []
|
||||||
|
const options: Array<StreamOptions | undefined> = []
|
||||||
let instruction: string | Instructions.Unavailable = "Initial context"
|
let instruction: string | Instructions.Unavailable = "Initial context"
|
||||||
const sessionID = SessionSchema.ID.make("ses_generate_test")
|
const sessionID = SessionSchema.ID.make("ses_generate_test")
|
||||||
|
|
||||||
const model = LanguageModel.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
|
const model = LanguageModel.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
|
||||||
const client = Layer.mock(LLMClient.Service)({
|
const client = Layer.mock(LLMClient.Service)({
|
||||||
stream: () => Stream.die(new Error("unused")),
|
stream: () => Stream.die(new Error("unused")),
|
||||||
generate: (request) =>
|
generate: (request, requestOptions) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
requests.push(request)
|
requests.push(request)
|
||||||
|
options.push(requestOptions)
|
||||||
const response = LLMResponse.fromEvents([
|
const response = LLMResponse.fromEvents([
|
||||||
LLMEvent.stepStart({ index: 0 }),
|
LLMEvent.stepStart({ index: 0 }),
|
||||||
LLMEvent.textStart({ id: "generate" }),
|
LLMEvent.textStart({ id: "generate" }),
|
||||||
@@ -221,6 +224,7 @@ const setup = Effect.gen(function* () {
|
|||||||
it.effect("generates from fresh settled Session context without durable mutation", () =>
|
it.effect("generates from fresh settled Session context without durable mutation", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
requests.length = 0
|
requests.length = 0
|
||||||
|
options.length = 0
|
||||||
instruction = "Initial context"
|
instruction = "Initial context"
|
||||||
const { db, bus, instructions } = yield* setup
|
const { db, bus, instructions } = yield* setup
|
||||||
yield* InstructionState.prepare(db, bus, instructions, sessionID)
|
yield* InstructionState.prepare(db, bus, instructions, sessionID)
|
||||||
@@ -292,6 +296,7 @@ it.effect("generates from fresh settled Session context without durable mutation
|
|||||||
if (event.tools.lookup) event.tools.lookup.description = "Hooked lookup"
|
if (event.tools.lookup) event.tools.lookup.description = "Hooked lookup"
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
yield* hooks.register("session", "http.request", () => Effect.void)
|
||||||
|
|
||||||
const generate = yield* SessionGenerate.Service
|
const generate = yield* SessionGenerate.Service
|
||||||
const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" })
|
const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" })
|
||||||
@@ -321,6 +326,8 @@ it.effect("generates from fresh settled Session context without durable mutation
|
|||||||
).toEqual(["Settled partial answer"])
|
).toEqual(["Settled partial answer"])
|
||||||
expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }])
|
expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }])
|
||||||
expect(requests[0]?.toolChoice).toBeUndefined()
|
expect(requests[0]?.toolChoice).toBeUndefined()
|
||||||
|
expect(options[0]?.http).toBeFunction()
|
||||||
|
expect(options[0]?.webSocket).toBeUndefined()
|
||||||
expect(yield* durableState(db, sessionID)).toEqual(before)
|
expect(yield* durableState(db, sessionID)).toEqual(before)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1004,9 +1004,16 @@ describe("SessionRunnerLLM", () => {
|
|||||||
const database = yield* Database.Service
|
const database = yield* Database.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
yield* InstructionState.prepare(database.db, bus, selected.instructions, sessionID)
|
yield* InstructionState.prepare(database.db, bus, selected.instructions, sessionID)
|
||||||
|
const loaded = yield* context.load(selected)
|
||||||
const prepared = yield* modelRequests.prepare({
|
const prepared = yield* modelRequests.prepare({
|
||||||
context: yield* context.load(selected),
|
scope: {
|
||||||
step: 1,
|
session: loaded.session,
|
||||||
|
agentID: loaded.agent.id,
|
||||||
|
model: loaded.model,
|
||||||
|
tools: loaded.tools,
|
||||||
|
},
|
||||||
|
transcript: { system: [], messages: [] },
|
||||||
|
webSocket: "session",
|
||||||
})
|
})
|
||||||
const http = prepared.options.http ?? (yield* Effect.die("Expected Session HTTP middleware"))
|
const http = prepared.options.http ?? (yield* Effect.die("Expected Session HTTP middleware"))
|
||||||
|
|
||||||
@@ -1015,7 +1022,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("network")))
|
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("network")))
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(prepared.webSocketEligible).toBe(false)
|
expect(prepared.options.webSocket).toBeUndefined()
|
||||||
expect(response.headers["x-response-hook"]).toBe("active")
|
expect(response.headers["x-response-hook"]).toBe("active")
|
||||||
expect(requestTriggers).toBe(1)
|
expect(requestTriggers).toBe(1)
|
||||||
expect(responseTriggers).toBe(1)
|
expect(responseTriggers).toBe(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user