Compare commits

..
2 Commits
21 changed files with 320 additions and 69 deletions
@@ -1,7 +1,8 @@
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenAIResponses } from "../protocols/openai-responses.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
@@ -37,11 +38,10 @@ const responsesRoute = Route.make({
id: "bedrock-mantle-responses",
provider: id,
providerMetadataKey: "mantle",
protocol: OpenAIResponses.protocol,
endpoint: OpenAIResponses.route.endpoint,
auth: OpenAIResponses.route.auth,
transport: OpenAIResponses.httpTransport,
defaults: OpenAIResponses.route.defaults,
protocol: OpenResponses.protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: OpenResponses.httpTransport,
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
const chatRoute = OpenAIChat.route.with({
@@ -4,7 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message } from "../../src/index.js"
import { AmazonBedrockMantle } from "../../src/providers.js"
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { withProcessEnv } from "../lib/env.js"
@@ -25,7 +25,7 @@ describe("Amazon Bedrock Mantle provider", () => {
expect(provider.model).toBe(provider.responses)
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
expect(model).toBe(AmazonBedrockMantle.responsesModel)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
const responses = yield* compileRequest(
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
@@ -38,7 +38,7 @@ describe("Amazon Bedrock Mantle provider", () => {
})
expect(responses).toMatchObject({
route: "bedrock-mantle-responses",
protocol: "openai-responses",
protocol: "open-responses",
body: { model: "openai.gpt-oss-120b", store: false },
})
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
@@ -178,7 +178,7 @@ describe("Amazon Bedrock Mantle provider", () => {
const recorded = recordedTests({
prefix: "bedrock-mantle",
provider: "amazon-bedrock",
protocol: "openai-responses",
protocol: "open-responses",
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
metadata: { model: "openai.gpt-oss-120b" },
})
@@ -122,15 +122,7 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
)
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
await expect(compaction).toContainText("Streamed implementation details.")
const running = compaction.getByRole("status").getByLabel("Compacting", { exact: true })
await expect(running).toBeVisible()
await expect
.poll(async () => {
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
const status = await running.boundingBox()
return !!summary && !!status && status.y >= summary.y + summary.height
})
.toBe(true)
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
await timeline.send(
+1 -1
View File
@@ -163,7 +163,7 @@ function mapProviderOptions(settings: Readonly<Record<string, unknown>>, exclude
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
const settings = input.settings
const chat = input.modelID === "openai.gpt-oss-safeguard-20b" || input.modelID === "openai.gpt-oss-safeguard-120b"
const chat = input.modelID.includes("gpt-oss")
return {
package: `@opencode/ai/providers/amazon-bedrock/mantle/${chat ? "chat" : "responses"}`,
settings: {
+5 -3
View File
@@ -54,7 +54,7 @@ function make(
return define({
id,
effect: Effect.fn(`OptimizePlugin.${id}`)(function* (ctx) {
yield* ctx.session.hook("context", (event) =>
const hook = (event: SessionHooks["context"]) =>
Effect.gen(function* () {
const model =
(yield* ctx.catalog.model.list()).data.find(
@@ -67,8 +67,10 @@ function make(
const system = event.system[0]
if (!system) return
event.system[0] = { ...system, text: SessionSystemPrompt.render(template, Object.keys(event.tools)) }
}).pipe(Effect.catch(() => Effect.void)),
)
}).pipe(Effect.catch(() => Effect.void))
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
yield* ctx.session.hook("generate", hook)
}),
})
}
+6 -3
View File
@@ -1,6 +1,7 @@
export * as WarmingPlugin from "./warming.js"
import { define } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import type { Session } from "@opencode/schema/session"
import { Clock, Duration, Effect, Scope } from "effect"
import { Config } from "../config.js"
@@ -54,7 +55,7 @@ export const Plugin = define({
},
)
yield* ctx.session.hook("context", (event) =>
const hook = (event: SessionHooks["context"]) =>
Effect.gen(function* () {
const active = sessions.get(event.sessionID)
const settings = yield* loadSettings()
@@ -95,7 +96,9 @@ export const Plugin = define({
),
Effect.forkIn(scope),
)
}),
)
})
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
yield* ctx.session.hook("generate", hook)
}),
})
+36
View File
@@ -11,6 +11,7 @@ import {
Message,
type ContentPart,
} from "@opencode/ai"
import type { SessionCompactionResult } from "@opencode/plugin/effect/session"
import { SessionError } from "@opencode/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Bus } from "../bus.js"
@@ -402,6 +403,36 @@ export const layer = Layer.effect(
recent,
inputID: input.inputID,
})
const supplied = Effect.fn("SessionCompaction.supplied")(function* (
input: ExecuteInput,
result: SessionCompactionResult,
recent: string,
) {
const context = input.context
const usage = result.tokens
? { tokens: result.tokens, cost: SessionUsage.calculateCost(context.model.cost, result.tokens) }
: undefined
if (usage)
yield* bus.publish(SessionEvent.UsageRecorded, {
sessionID: context.session.id,
source: "compaction",
...usage,
})
yield* bus.publish(
SessionEvent.Compaction.Ended,
{
sessionID: context.session.id,
reason: input.reason,
model: context.model.ref,
providerState: result.providerState,
text: result.summary,
recent,
...usage,
},
{ metadata: result.metadata },
)
return { status: "completed" as const }
})
// Manual controls settle through the inbox; only automatic work needs a durable interruption record.
const interrupted = (input: ExecuteInput) =>
input.reason === "auto"
@@ -456,6 +487,10 @@ export const layer = Layer.effect(
error: { type: "provider.unsupported-operation", message },
})
const prepared = yield* compactionRequest(input, context.messages, [], "session")
if (prepared.event.result) {
yield* started(input, "")
return yield* supplied(input, prepared.event.result, "")
}
const request = prepared.request
const provenance = SessionProviderContext.provenance(context.model)
if (!provenance) return yield* reject("Provider compaction requires a stable, configured endpoint")
@@ -571,6 +606,7 @@ export const layer = Layer.effect(
const prepared = yield* compactionRequest(input, history.messages, [
Message.user(buildPrompt(previous !== undefined, legacy)),
])
if (prepared.event.result) return yield* supplied(input, prepared.event.result, history.recent)
// Both requests share the retry allowance; rejected output never enters the reminder request.
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
agent: context.agent.id,
@@ -409,6 +409,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
yield* adapter.updateCompaction({
...current,
status: "completed",
metadata: event.metadata ? { ...current.metadata, ...event.metadata } : current.metadata,
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
+16 -8
View File
@@ -11,7 +11,14 @@ import {
SystemPart,
} from "@opencode/ai"
import type { StreamOptions } from "@opencode/ai/route"
import type { SessionContext, SessionRequest, SessionRequestKind, SessionTitle } from "@opencode/plugin/effect/session"
import type {
SessionCompaction,
SessionContext,
SessionGenerate,
SessionRequest,
SessionRequestKind,
SessionTitle,
} from "@opencode/plugin/effect/session"
import type { Agent } from "@opencode/schema/agent"
import type { Model } from "@opencode/schema/model"
import type { Content } from "@opencode/schema/tool"
@@ -178,8 +185,8 @@ type Definitions = PluginHooks.Domains["session"]["context"]["tools"]
/** Builds the model request for each session flow. Each entry runs its own plugin hook. */
export interface Interface {
readonly primary: (input: Input) => Effect.Effect<Prepared<SessionContext>>
readonly compaction: (input: Input) => Effect.Effect<Prepared<SessionContext>>
readonly generate: (input: Input) => Effect.Effect<Prepared<SessionContext>>
readonly compaction: (input: Input) => Effect.Effect<Prepared<SessionCompaction>>
readonly generate: (input: Input) => Effect.Effect<Prepared<SessionGenerate>>
readonly title: (input: Input) => Effect.Effect<Prepared<SessionTitle>>
}
@@ -342,13 +349,14 @@ export const layer = Layer.effect(
}
})
const context = (agent: Agent.ID) => (draft: SessionRequest, tools: Definitions) =>
hooks.trigger("session", "context", { ...draft, agent, tools })
const agentHook =
(name: "context" | "compaction" | "generate", agent: Agent.ID) => (draft: SessionRequest, tools: Definitions) =>
hooks.trigger("session", name, { ...draft, agent, tools })
return Service.of({
primary: (input) => prepare("primary", input, context(input.agent)),
generate: (input) => prepare("generate", input, context(input.agent)),
compaction: (input) => prepare("compaction", input, context(input.agent)),
primary: (input) => prepare("primary", input, agentHook("context", input.agent)),
compaction: (input) => prepare("compaction", input, agentHook("compaction", input.agent)),
generate: (input) => prepare("generate", input, agentHook("generate", input.agent)),
title: (input) => prepare("title", input, (draft) => hooks.trigger("session", "title", draft)),
})
}),
+3 -3
View File
@@ -175,7 +175,7 @@ describe("AISDKNative", () => {
settings: { region: "us-east-1" },
})
expect(map("@ai-sdk/amazon-bedrock/mantle", { region: "us-east-1" }, "openai.gpt-oss-120b")).toEqual({
package: "@opencode/ai/providers/amazon-bedrock/mantle/responses",
package: "@opencode/ai/providers/amazon-bedrock/mantle/chat",
settings: { region: "us-east-1" },
})
})
@@ -287,7 +287,7 @@ describe("AISDKNative", () => {
}
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-120b")).toEqual({
package: "@opencode/ai/providers/amazon-bedrock/mantle/responses",
package: "@opencode/ai/providers/amazon-bedrock/mantle/chat",
settings: {
apiKey: "token",
baseURL: "https://mantle.test/v1",
@@ -336,7 +336,7 @@ describe("AISDKNative", () => {
"openai.gpt-oss-120b",
),
).toEqual({
package: "@opencode/ai/providers/amazon-bedrock/mantle/responses",
package: "@opencode/ai/providers/amazon-bedrock/mantle/chat",
settings: {
credentials: {
accessKeyId: "key",
+5 -5
View File
@@ -169,19 +169,19 @@ describe("ModelResolver", () => {
),
)
it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () =>
it.effect("maps Bedrock Mantle GPT-OSS models to Chat and other models to Responses", () =>
Effect.gen(function* () {
const credential = Credential.Key.make({ type: "key", key: "secret" })
const responses = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
modelID: "openai.gpt-oss-120b",
modelID: "openai.gpt-5.5",
settings: { region: "us-east-2" },
}),
credential,
)
const chat = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
modelID: "openai.gpt-oss-safeguard-20b",
modelID: "openai.gpt-oss-20b",
settings: { region: "us-east-2" },
}),
credential,
@@ -1041,7 +1041,7 @@ describe("ModelResolver", () => {
["@ai-sdk/amazon-bedrock", "@opencode/ai/providers/amazon-bedrock", "api-model"],
[
"@ai-sdk/amazon-bedrock/mantle",
"@opencode/ai/providers/amazon-bedrock/mantle/responses",
"@opencode/ai/providers/amazon-bedrock/mantle/chat",
"openai.gpt-oss-120b",
],
["@ai-sdk/azure", "@opencode/ai/providers/azure/responses", "api-model"],
@@ -1271,7 +1271,7 @@ describe("ModelResolver", () => {
expect(bedrock.route.id).toBe("bedrock-converse")
expect(bedrock.route.defaults.generation).toEqual({ topP: 0.8 })
expect(bedrock.route.defaults.http?.body).toEqual({ serviceTier: { type: "priority" } })
expect(mantle.route.id).toBe("bedrock-mantle-responses")
expect(mantle.route.id).toBe("bedrock-mantle-chat")
expect(mantle.route.defaults.generation).toEqual({ topP: 0.6 })
}),
)
@@ -11,6 +11,7 @@ import { SessionCompaction } from "@opencode/core/session/compaction"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionMessage } from "@opencode/core/session/message"
import { SessionModelRequest } from "@opencode/core/session/model-request"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { SessionProjector } from "@opencode/core/session/projector"
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
import { SessionTable } from "@opencode/core/session/sql"
@@ -85,6 +86,7 @@ const it = testEffect(
SessionStore.node,
SessionCompaction.node,
SessionModelRequest.node,
PluginHooks.node,
]),
[Bus.node.replace(Bus.configured({ persist: true })), llmClient.replace(client)],
),
@@ -440,6 +442,65 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
}),
)
it.effect("compaction hooks can supply the summary instead of the model", () =>
Effect.gen(function* () {
requests = []
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const hooks = yield* PluginHooks.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_hooked_compaction")
const session = yield* insertSession(sessionID)
const modelRequests = yield* SessionModelRequest.Service
const messages = [
{
id: SessionMessage.ID.create(),
type: "user" as const,
text: "Hooked compaction should see this conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
]
let contexts = 0
yield* hooks.register("session", "context", () => Effect.sync(() => contexts++))
yield* hooks.register("session", "compaction", (event) =>
Effect.sync(() => {
expect(event.sessionID).toBe(sessionID)
expect(event.agent).toBe(Agent.defaultID)
expect(JSON.stringify(event.messages)).toContain("Hooked compaction should see this conversation.")
event.result = { summary: "## Objective\n- hooked summary" }
}),
)
expect(
yield* compaction.compactManual({
session,
resolveContext: () => Effect.succeed(loaded(session, messages)),
prepare: modelRequests.compaction,
messages,
inputID: SessionMessage.ID.make("msg_hooked_compaction"),
}),
).toEqual({ status: "completed" })
expect(contexts).toBe(0)
expect(requests).toEqual([])
expect(yield* store.context(sessionID)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "## Objective\n- hooked summary", recent: "" },
])
expect(
yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie),
).toEqual([
{ type: Bus.versionedType(SessionEvent.Compaction.Started.type, 1) },
{ type: Bus.versionedType(SessionEvent.Compaction.Ended.type, 1) },
])
}),
)
it.effect("manual compaction records model resolution failures without calling the model", () =>
Effect.gen(function* () {
requests = []
+49 -1
View File
@@ -1,5 +1,14 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, LLMResponse, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode/ai"
import {
LLMClient,
LLMEvent,
LLMResponse,
LanguageModel,
Message,
SystemPart,
ToolDefinition,
type LLMRequest,
} from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
import type { StreamOptions } from "@opencode/ai/route"
import { Agent } from "@opencode/core/agent"
@@ -38,6 +47,7 @@ import {
import { SessionStore } from "@opencode/core/session/store"
import { SkillInstructions } from "@opencode/core/skill/instructions"
import { Plugin } from "@opencode/core/plugin"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { PluginSupervisor } from "@opencode/core/plugin/supervisor"
import { Tool } from "@opencode/core/tool"
import { asc, eq } from "drizzle-orm"
@@ -134,6 +144,7 @@ const it = testEffect(
Agent.node,
InstructionBuiltIns.node,
SessionContext.node,
PluginHooks.node,
llmClient,
]),
[
@@ -344,6 +355,43 @@ it.effect(
{ timeout: 15_000 },
)
it.effect(
"runs generate hooks instead of context hooks",
() =>
Effect.gen(function* () {
requests.length = 0
instruction = "Initial context"
const { db, bus, instructions, session, instances } = yield* setup
yield* InstructionState.prepare(db, bus, instructions, sessionID)
const hooks = yield* PluginHooks.Service
let contexts = 0
yield* hooks.register("session", "context", () => Effect.sync(() => contexts++))
yield* hooks.register("session", "generate", (event) =>
Effect.sync(() => {
expect(event.sessionID).toBe(sessionID)
expect(event.agent).toBe(Agent.ID.make("build"))
expect(Object.keys(event.tools)).toEqual(["lookup"])
event.system.push(SystemPart.make("Answer briefly."))
event.messages = [Message.user("[redacted]")]
event.options.maxTokens = 32
event.options.reasoningEffort = "low"
}),
)
yield* SessionGenerate.generate({ session, prompt: "Summarize privately" }).pipe(
Effect.provideService(Instance.Service, instances),
)
expect(contexts).toBe(0)
expect(requests).toHaveLength(1)
expect(requests[0]?.system.map((part) => part.text)).toContain("Answer briefly.")
expect(userTexts(requests[0])).toEqual(["[redacted]"])
expect(requests[0]?.generation).toEqual(expect.objectContaining({ maxTokens: 32 }))
expect(requests[0]?.providerOptions).toEqual({ reasoningEffort: "low" })
}),
{ timeout: 15_000 },
)
it.effect(
"blocks unavailable initial instructions before generation",
() =>
@@ -400,6 +400,34 @@ it.live("only known automatic native overflow falls back locally and failed reco
}),
)
it.live("compaction hooks supply the summary instead of provider compaction", () =>
Effect.gen(function* () {
const fixture = yield* setup()
yield* fixture.prompt("Original user")
yield* fixture.hooks.register("session", "compaction", (event) =>
Effect.sync(() => {
event.result = {
summary: "## Objective\n- hooked summary",
providerState: { responseId: "plugin" },
metadata: { plugin: "custom" },
tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } },
}
}),
)
expect(yield* fixture.compact).toEqual({ status: "completed" })
expect(fixture.state.calls).toBe(0)
expect((yield* fixture.load).messages.at(-1)).toMatchObject({
type: "compaction",
status: "completed",
summary: "## Objective\n- hooked summary",
recent: "",
providerState: { responseId: "plugin" },
metadata: { plugin: "custom" },
tokens: { input: 10, output: 5 },
})
}),
)
it.live("rejects request-hook route rewrites before provider compaction", () =>
Effect.gen(function* () {
const fixture = yield* setup()
+5 -3
View File
@@ -20,6 +20,7 @@ import { OpenAIChat } from "@opencode/ai/protocols/openai-chat"
import { AnthropicMessages, OpenAIResponses } from "@opencode/ai/protocols"
import { compileRequest } from "@opencode/ai/route/client"
import { TestLLM } from "@opencode/ai/testing"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { Catalog } from "@opencode/core/catalog"
import { Database } from "@opencode/core/database/database"
import { makeLocationNode } from "@opencode/util/effect/app-node"
@@ -2411,15 +2412,16 @@ describe("SessionRunnerLLM", () => {
model: { id: ID.make(s.currentModel.id), providerID: Provider.ID.make(s.currentModel.provider), variant },
})
const requestAgents: Agent.ID[] = []
yield* hooks.register("session", "context", (event) =>
const hook = (event: SessionHooks["context"]) =>
Effect.sync(() => {
expect(event.agent).toBe(agentID)
expect(event.model.variant).toBe(variant)
event.system.push(SystemPart.make("Hook-provided instructions"))
event.tools.echo.description = "Hook-provided tool description"
event.options.maxTokens = 4_000
}),
)
})
yield* hooks.register("session", "context", hook)
yield* hooks.register("session", "compaction", hook)
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
requestAgents.push(event.agent)
+17
View File
@@ -7,6 +7,7 @@ import type { Session } from "@opencode/schema/session"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { SessionError } from "@opencode/schema/session-error"
import type { SessionMessage } from "@opencode/schema/session-message"
import type { TokenUsage } from "@opencode/schema/token-usage"
import type { JsonSchema, Types } from "effect"
import type { ModelHooks } from "./registration.js"
@@ -34,6 +35,20 @@ export interface SessionContext extends SessionRequest {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionCompactionResult {
summary: string
providerState?: SessionMessage.ProviderState
metadata?: Record<string, unknown>
tokens?: TokenUsage.Info
}
export interface SessionCompaction extends SessionContext {
/** Set to use this compaction and skip the model request. */
result?: SessionCompactionResult
}
export interface SessionGenerate extends SessionContext {}
export interface SessionTitle extends SessionRequest {
/** Set to use this title and skip the model request. */
result?: string
@@ -85,6 +100,8 @@ export interface SessionRetry {
export interface SessionHooks {
readonly prompt: SessionPrompt
readonly context: SessionContext
readonly compaction: SessionCompaction
readonly generate: SessionGenerate
readonly title: SessionTitle
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
+17
View File
@@ -7,6 +7,7 @@ import type { Session } from "@opencode/schema/session"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { SessionError } from "@opencode/schema/session-error"
import type { SessionMessage } from "@opencode/schema/session-message"
import type { TokenUsage } from "@opencode/schema/token-usage"
import type { JsonSchema, Types } from "effect"
import type { ModelHooks } from "./registration.js"
@@ -34,6 +35,20 @@ export interface SessionContext extends SessionRequest {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionCompactionResult {
summary: string
providerState?: SessionMessage.ProviderState
metadata?: Record<string, unknown>
tokens?: TokenUsage.Info
}
export interface SessionCompaction extends SessionContext {
/** Set to use this compaction and skip the model request. */
result?: SessionCompactionResult
}
export interface SessionGenerate extends SessionContext {}
export interface SessionTitle extends SessionRequest {
/** Set to use this title and skip the model request. */
result?: string
@@ -85,6 +100,8 @@ export interface SessionRetry {
export interface SessionHooks {
readonly prompt: SessionPrompt
readonly context: SessionContext
readonly compaction: SessionCompaction
readonly generate: SessionGenerate
readonly title: SessionTitle
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
@@ -13,6 +13,7 @@ import { Plugin } from "@opencode/core/plugin"
import { Session } from "@opencode/core/session"
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
import { define } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { Agent } from "@opencode/schema/agent"
import { Location } from "@opencode/schema/location"
import { AbsolutePath } from "@opencode/schema/schema"
@@ -92,11 +93,12 @@ it.live(
event.prompt.text += ` [${config.tool}]`
}),
)
yield* ctx.session.hook("context", (event) =>
const tune = (event: SessionHooks["context"]) =>
Effect.sync(() => {
event.options.temperature = config.temperature
}),
)
})
yield* ctx.session.hook("context", tune)
yield* ctx.session.hook("generate", tune)
yield* ctx.permission.hook("evaluate", (event) =>
Effect.sync(() => {
event.effect = event.action === "instance-test" ? "ask" : "allow"
@@ -427,17 +427,6 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
<div class="py-2">
<TimelineSeparator label={i18n.t("ui.messagePart.compaction.started")} />
</div>
<Show when={summary().trim()}>
<div data-component="text-part" data-timeline-part-id={props.message.id}>
<div data-slot="text-part-body">
<PacedMarkdown
text={summary()}
cacheKey={props.message.id}
streaming={props.message.status === "running"}
/>
</div>
</div>
</Show>
<Show when={props.message.status === "running"}>
<div role="status" class="py-2">
<BasicTool
@@ -449,6 +438,17 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
/>
</div>
</Show>
<Show when={summary().trim()}>
<div data-component="text-part" data-timeline-part-id={props.message.id}>
<div data-slot="text-part-body">
<PacedMarkdown
text={summary()}
cacheKey={props.message.id}
streaming={props.message.status === "running"}
/>
</div>
</div>
</Show>
<Show when={props.message.status !== "running"}>
<div class="py-2">
<TimelineSeparator label={label()} />
@@ -1089,7 +1089,9 @@ effect: (ctx) =>
### Sessions
Modify assembled system instructions, messages, or tools immediately before model dispatch.
Modify assembled system instructions, messages, or tools immediately before model dispatch. `context` runs for the
agent loop; `compaction`, `generate`, and `title` run for those auxiliary requests. `compaction` and `title` accept a
`result` that skips the model call.
```ts
effect: (ctx) =>
@@ -1101,6 +1103,11 @@ effect: (ctx) =>
delete event.tools.write
}),
)
yield* session.hook("compaction", (event) =>
Effect.map(summarize(event.messages), (summary) => {
event.result = { summary }
}),
)
}),
```
@@ -1178,6 +1185,9 @@ Context-overflow recovery remains separate because it compacts the conversation
```ts
interface SessionHooks {
readonly context: SessionContext
readonly compaction: SessionCompaction
readonly generate: SessionGenerate
readonly title: SessionTitle
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
@@ -1197,9 +1197,17 @@ Keep prompt hooks retry-safe. They are not an exactly-once side-effect boundary:
- Concurrent submissions can run hooks more than once, but only the first successful admission wins.
- Prompt hooks transform input and do not expose a typed rejection API.
#### Model context
#### Model requests
Modify assembled system instructions, messages, tools, or request options immediately before model dispatch.
Modify assembled system instructions, messages, tools, or request options immediately before model dispatch. Each
kind of request a session issues has its own hook, so a plugin can treat the agent loop and auxiliary requests
differently:
- `context` runs for the agent loop, including tool-driven continuations.
- `compaction` runs for checkpoint summaries. Set `result` to record the compaction yourself and skip the model
call; it takes the same fields as a completed compaction message.
- `generate` runs for transient `ctx.session.generate` calls.
- `title` runs for title generation. It has no `agent` or `tools`. Set `result` to supply the title yourself.
```ts
await ctx.session.hook("context", (event) => {
@@ -1208,11 +1216,14 @@ await ctx.session.hook("context", (event) => {
event.options.temperature = 0.2
event.options.maxTokens = 8_000
})
await ctx.session.hook("compaction", async (event) => {
event.result = { summary: await summarize(event.messages) }
})
```
Context changes affect only the outgoing model call, not persisted history or
configuration. The hook runs again for subsequent calls such as tool-driven
continuations, transient session generation, and compaction, but not for title requests.
Changes affect only the outgoing model call, not persisted history or configuration. A hook that should apply to
every request must register for each kind.
Request overrides follow these rules:
@@ -1321,6 +1332,9 @@ import type { SessionPrompt } from "@opencode/plugin/promise/session"
interface SessionHooks {
prompt: SessionPrompt
context: SessionContextHook
compaction: SessionContextHook & { result?: SessionCompactionResult }
generate: SessionContextHook
title: SessionRequestHook & { result?: string }
"model.request": SessionModelRequestHook
"http.request": SessionHttpRequestHook
"http.response": SessionHttpResponseHook
@@ -1338,13 +1352,11 @@ interface SessionRetryHook {
decision: RetryDecision
}
interface SessionContextHook {
interface SessionRequestHook {
readonly sessionID: string
readonly agent: string
readonly model: { providerID: string; id: string; variant?: string }
system: SystemPart[]
messages: Message[]
tools: Record<string, { description: string; input: JsonSchema }>
options: {
maxTokens?: number
temperature?: number
@@ -1357,6 +1369,18 @@ interface SessionContextHook {
} & Record<string, unknown>
}
interface SessionContextHook extends SessionRequestHook {
readonly agent: string
tools: Record<string, { description: string; input: JsonSchema }>
}
interface SessionCompactionResult {
summary: string
providerState?: Record<string, unknown>
metadata?: Record<string, unknown>
tokens?: TokenUsage
}
interface SessionHookContext {
hook<Name extends keyof SessionHooks>(
name: Name,