diff --git a/.changeset/unify-request-envelope.md b/.changeset/unify-request-envelope.md new file mode 100644 index 00000000000..8f06625fe44 --- /dev/null +++ b/.changeset/unify-request-envelope.md @@ -0,0 +1,5 @@ +--- +"@opencode-ai/core": patch +--- + +Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: plugins that shape the agent conversation do not observe title or compaction requests. Title requests gain the fork-aware session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own. diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index d401efbd48e..08bb4c5d601 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -1,6 +1,6 @@ export * as SessionCompaction from "./compaction.js" -import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai" +import { LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai" import type { StreamOptions } from "@opencode-ai/ai/route" import { SessionError } from "@opencode-ai/schema/session-error" import { Context, Effect, Layer, Stream } from "effect" @@ -9,17 +9,12 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { llmClient } from "../effect/app-node-platform.js" import { SessionEvent } from "./event.js" import type { SessionMessage } from "./message.js" -import { SessionModelHeaders } from "./model-headers.js" -import { SessionModelHook } from "./model-hook.js" -import { SessionModelHttp } from "./model-http.js" -import { SessionPromptCacheKey } from "./prompt-cache-key.js" -import { App } from "../app.js" +import { SessionModelRequest } from "./model-request.js" import { SessionRunnerModel } from "./runner/model.js" import { SessionSchema } from "./schema.js" import { toSessionError } from "./to-session-error.js" import { Token } from "../util/token.js" import { SessionUsage } from "./usage.js" -import { PluginHooks } from "../plugin/hooks.js" import { Agent } from "../agent.js" import { State } from "../state.js" @@ -70,13 +65,12 @@ export type Draft = { } type Dependencies = { - readonly app: App.Info readonly bus: Bus.Interface readonly llm: { readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream } readonly models: SessionRunnerModel.Interface - readonly hooks: PluginHooks.Interface + readonly modelRequests: SessionModelRequest.Interface } export type AutoInput = { @@ -85,6 +79,8 @@ export type AutoInput = { readonly resolved: SessionRunnerModel.Resolved } +type RequiredInput = Pick + export type ManualInput = { readonly session: SessionSchema.Info readonly messages: readonly SessionMessage.Info[] @@ -92,8 +88,6 @@ export type ManualInput = { readonly started?: boolean } -type RequiredInput = Pick - type Plan = { readonly session: SessionSchema.Info readonly resolved: SessionRunnerModel.Resolved @@ -278,65 +272,51 @@ const make = (dependencies: Dependencies) => { }) : Effect.void, ) - const request = yield* SessionModelHook.apply( - dependencies.hooks, - { sessionID: plan.session.id, agent: Agent.ID.make("compaction"), model: plan.resolved.ref }, - LLM.request({ - model: plan.resolved.model, - promptCacheKey: SessionPromptCacheKey.make(plan.session.id), - http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) }, - messages: [Message.user(plan.prompt)], - tools: [], + const prepared = yield* dependencies.modelRequests.prepare({ + scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved }, + transcript: { system: [], messages: [Message.user(plan.prompt)] }, + contextHooks: false, + }) + yield* dependencies.llm.stream(prepared.request, prepared.options).pipe( + Stream.runForEach((event) => { + if (LLMEvent.is.providerError(event)) + failure = { + type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error", + message: event.message, + } + if (LLMEvent.is.textDelta(event)) { + chunks.push(event.text) + return dependencies.bus.publish(SessionEvent.Compaction.Delta, { + sessionID: plan.session.id, + text: event.text, + }) + } + if (LLMEvent.is.stepFinish(event)) { + const step = SessionUsage.record(event.usage, plan.resolved.cost) + usage = usage ? SessionUsage.add(usage, step) : step + } + return Effect.void }), - ) - yield* dependencies.llm - .stream(request, { - http: SessionModelHttp.middleware(dependencies.hooks, { - sessionID: plan.session.id, - agent: Agent.ID.make("compaction"), - model: plan.resolved.ref, + Effect.catchTag("AI.Error", (error) => + Effect.sync(() => { + failure = toSessionError(error) }), - }) - .pipe( - Stream.runForEach((event) => { - if (LLMEvent.is.providerError(event)) - failure = { - type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error", - message: event.message, - } - if (LLMEvent.is.textDelta(event)) { - chunks.push(event.text) - return dependencies.bus.publish(SessionEvent.Compaction.Delta, { - sessionID: plan.session.id, - text: event.text, - }) - } - if (LLMEvent.is.stepFinish(event)) { - const step = SessionUsage.record(event.usage, plan.resolved.cost) - usage = usage ? SessionUsage.add(usage, step) : step - } - return Effect.void - }), - Effect.catchTag("AI.Error", (error) => - Effect.sync(() => { - failure = toSessionError(error) - }), - ), - Effect.onInterrupt(() => - recordUsage.pipe( - Effect.andThen( - plan.reason === "auto" - ? failed({ - sessionID: plan.session.id, - reason: plan.reason, - error: { type: "compaction.interrupted", message: "Compaction was interrupted" }, - inputID: plan.inputID, - }).pipe(Effect.asVoid) - : Effect.void, - ), + ), + Effect.onInterrupt(() => + recordUsage.pipe( + Effect.andThen( + plan.reason === "auto" + ? failed({ + sessionID: plan.session.id, + reason: plan.reason, + error: { type: "compaction.interrupted", message: "Compaction was interrupted" }, + inputID: plan.inputID, + }).pipe(Effect.asVoid) + : Effect.void, ), ), - ) + ), + ) yield* recordUsage const summary = chunks.join("") if (failure || !summary.trim()) { @@ -437,14 +417,13 @@ export const layer = Layer.effect( const bus = yield* Bus.Service const llm = yield* LLMClient.Service const models = yield* SessionRunnerModel.Service - const app = yield* App.Metadata - const hooks = yield* PluginHooks.Service - return make({ bus, llm, models, app, hooks }) + const modelRequests = yield* SessionModelRequest.Service + return make({ bus, llm, models, modelRequests }) }), ) export const node = makeLocationNode({ service: Service, layer, - deps: [Bus.node, llmClient, SessionRunnerModel.node, App.node, PluginHooks.node], + deps: [Bus.node, llmClient, SessionRunnerModel.node, SessionModelRequest.node], }) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index bd7f5a79e26..9e8d4c59b3e 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -61,13 +61,20 @@ interface PrepareInput { readonly session: SessionSchema.Info readonly agentID: Agent.ID readonly model: SessionRunnerModel.Resolved - readonly tools: Tool.Snapshot + /** Omitted for requests that carry no tools (title, compaction). */ + readonly tools?: Tool.Snapshot } readonly transcript: { readonly system: Array readonly messages: Array } readonly toolChoice?: LLM.RequestInput["toolChoice"] + /** + * Session context hooks shape the agent conversation. Requests that are not + * part of the conversation (title, compaction) opt out: their transcripts + * pass through unchanged. + */ + readonly contextHooks?: false /** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */ readonly webSocket?: "session" } @@ -209,7 +216,10 @@ export const layer = Layer.effect( const session = input.scope.session const resolved = input.scope.model const model = resolved.model - const tools = input.scope.tools + const tools = input.scope.tools ?? { + definitions: [], + execute: () => new Tool.Error({ message: "Tools are not available for this request" }), + } 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 // tool by moving its definition to a new key; recognizing the object recovers the tool. @@ -219,14 +229,18 @@ export const layer = Layer.effect( ), ) // Hooks mutate this record in place: edit descriptions and schemas, rename, or remove. - const context = yield* hooks.trigger("session", "context", { - sessionID: session.id, - agent: input.scope.agentID, - model: resolved.ref, - system: input.transcript.system, - messages: input.transcript.messages, - tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])), - }) + const definitions = Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])) + const context = + input.contextHooks === false + ? { system: input.transcript.system, messages: input.transcript.messages, tools: definitions } + : yield* hooks.trigger("session", "context", { + sessionID: session.id, + agent: input.scope.agentID, + model: resolved.ref, + system: input.transcript.system, + messages: input.transcript.messages, + tools: definitions, + }) // Match each surviving entry back to its tool, by recognizing a moved definition or // by key. Identity wins so a definition moved onto another tool's name still executes // the tool it describes. Entries matching neither were invented by a hook and dropped. diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 0e8be05f64d..1872c7dbf5a 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -323,7 +323,6 @@ const layer = Layer.effect( const loaded = yield* context.load(selected) const { session, agent } = loaded const resolved = loaded.model - const model = resolved.model // Make room: history must fit the context window before the call. A pending manual // compaction owns this instead; the runner executes it between steps. const compactionInput = { session, messages: loaded.messages, resolved } diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index 5524538c0da..f4fda46727c 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -1,6 +1,6 @@ export * as SessionTitle from "./title.js" -import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai" +import { LLMClient, AIError, LLMEvent, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai" import type { StreamOptions } from "@opencode-ai/ai/route" import { Context, DateTime, Effect, Layer, Stream } from "effect" import { Agent } from "../agent.js" @@ -8,14 +8,10 @@ import { Database } from "../database/database.js" import { Bus } from "../bus.js" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback" -import { App } from "../app.js" import { llmClient } from "../effect/app-node-platform.js" -import { PluginHooks } from "../plugin/hooks.js" import { SessionEvent } from "./event.js" import { SessionHistory } from "./history.js" -import { SessionModelHeaders } from "./model-headers.js" -import { SessionModelHook } from "./model-hook.js" -import { SessionModelHttp } from "./model-http.js" +import { SessionModelRequest } from "./model-request.js" import { SessionRunnerModel } from "./runner/model.js" import { SessionSchema } from "./schema.js" import { SessionUsage } from "./usage.js" @@ -25,15 +21,14 @@ const MAX_LENGTH = 100 const titleChanged = Symbol("Session title changed") type Dependencies = { - readonly app: App.Info readonly bus: Bus.Interface readonly llm: { readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream } readonly agents: Agent.Interface readonly models: SessionRunnerModel.Interface + readonly modelRequests: SessionModelRequest.Interface readonly store: SessionStore.Interface - readonly hooks: PluginHooks.Interface } export interface Interface { @@ -81,39 +76,28 @@ const make = (dependencies: Dependencies) => { }) : Effect.void, ) - const request = yield* SessionModelHook.apply( - dependencies.hooks, - { sessionID: session.id, agent: agent.id, model: resolved.ref }, - LLM.request({ - model: resolved.model, - http: { headers: SessionModelHeaders.make(session, dependencies.app) }, - system: agent.system, + const prepared = yield* dependencies.modelRequests.prepare({ + scope: { session, agentID: agent.id, model: resolved }, + transcript: { + system: agent.system ? [SystemPart.make(agent.system)] : [], messages: [Message.user(firstUser.text)], - tools: [], + }, + contextHooks: false, + }) + const streamed = yield* dependencies.llm.stream(prepared.request, prepared.options).pipe( + Stream.runForEach((event) => { + if (LLMEvent.is.providerError(event)) failed = true + if (LLMEvent.is.textDelta(event)) chunks.push(event.text) + if (LLMEvent.is.stepFinish(event)) { + const step = SessionUsage.record(event.usage, resolved.cost) + usage = usage ? SessionUsage.add(usage, step) : step + } + return Effect.void }), + Effect.as(true), + Effect.catchTag("AI.Error", () => Effect.succeed(false)), + Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)), ) - const streamed = yield* dependencies.llm - .stream(request, { - http: SessionModelHttp.middleware(dependencies.hooks, { - sessionID: session.id, - agent: agent.id, - model: resolved.ref, - }), - }) - .pipe( - Stream.runForEach((event) => { - if (LLMEvent.is.providerError(event)) failed = true - if (LLMEvent.is.textDelta(event)) chunks.push(event.text) - if (LLMEvent.is.stepFinish(event)) { - const step = SessionUsage.record(event.usage, resolved.cost) - usage = usage ? SessionUsage.add(usage, step) : step - } - return Effect.void - }), - Effect.as(true), - Effect.catchTag("AI.Error", () => Effect.succeed(false)), - Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)), - ) yield* recordUsage if (!streamed || failed) return const title = chunks @@ -146,11 +130,10 @@ export const layer = Layer.effect( const llm = yield* LLMClient.Service const agents = yield* Agent.Service const models = yield* SessionRunnerModel.Service + const modelRequests = yield* SessionModelRequest.Service const store = yield* SessionStore.Service const database = yield* Database.Service - const app = yield* App.Metadata - const hooks = yield* PluginHooks.Service - const title = make({ bus, llm, agents, models, store, app, hooks }) + const title = make({ bus, llm, agents, models, modelRequests, store }) return Service.of({ generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID), }) @@ -165,9 +148,8 @@ export const node = makeLocationNode({ llmClient, Agent.node, SessionRunnerModel.node, + SessionModelRequest.node, SessionStore.node, Database.node, - App.node, - PluginHooks.node, ], }) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 81beb9bef17..dc47def484e 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai" +import { LLMClient, LLMEvent, LanguageModel, SystemPart, type LLMRequest } from "@opencode-ai/ai" import { OpenAIChat } from "@opencode-ai/ai/protocols" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -19,6 +19,7 @@ import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { App } from "@opencode-ai/core/app" import { Agent } from "@opencode-ai/core/agent" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { Money } from "@opencode-ai/schema/money" @@ -76,7 +77,14 @@ const models = Layer.mock(SessionRunnerModel.Service)({ }) const it = testEffect( AppNodeBuilder.build( - LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, SessionCompaction.node]), + LayerNode.group([ + Database.node, + Bus.node, + SessionProjector.node, + SessionStore.node, + PluginHooks.node, + SessionCompaction.node, + ]), [ [Bus.node, Bus.configured({ persist: true })], [llmClient, client], @@ -181,6 +189,35 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () => }), ) +/** Seeds the global project plus one session row, returning the projected session. */ +const insertSession = (id: Session.ID, overrides?: Partial) => + Effect.gen(function* () { + const db = (yield* Database.Service).db + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id, + project_id: Project.ID.global, + slug: id, + directory: "/project", + title: id, + version: "test", + ...overrides, + }) + .run() + .pipe(Effect.orDie) + const store = yield* SessionStore.Service + return yield* store + .get(id) + .pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die(`session missing: ${id}`)))) + }) + it.effect("manual compaction summarizes short context instead of no-op", () => Effect.gen(function* () { requests = [] @@ -196,33 +233,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () => text: "Manual compaction should include this short conversation.", time: { created: DateTime.makeUnsafe(0) }, } - yield* db - .insert(ProjectTable) - .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) - .onConflictDoNothing() - .run() - .pipe(Effect.orDie) - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: Project.ID.global, - parent_id: parentID, - slug: "manual-compaction", - directory: "/project", - title: "Manual compaction", - version: "test", - }) - .run() - .pipe(Effect.orDie) - - const session = yield* store - .get(sessionID) - .pipe( - Effect.flatMap((session) => - session ? Effect.succeed(session) : Effect.die("manual compaction test session missing"), - ), - ) + const session = yield* insertSession(sessionID, { parent_id: parentID }) const delta = yield* bus .subscribe(SessionEvent.Compaction.Delta) @@ -272,3 +283,66 @@ it.effect("manual compaction summarizes short context instead of no-op", () => ]) }), ) + +it.effect("forked session compaction reuses the fork root prompt cache key", () => + Effect.gen(function* () { + requests = [] + const compaction = yield* SessionCompaction.Service + const sessionID = Session.ID.make("ses_fork_compaction") + const rootID = Session.ID.make("ses_fork_compaction_root") + const session = yield* insertSession(sessionID, { + fork_session_id: rootID, + fork_boundary: { type: "before", messageID: SessionMessage.ID.create() }, + }) + expect( + yield* compaction.compactManual({ + session, + messages: [ + { + id: SessionMessage.ID.create(), + type: "user", + text: "Summarize the forked conversation.", + time: { created: DateTime.makeUnsafe(0) }, + }, + ], + inputID: SessionMessage.ID.make("msg_fork_compaction"), + }), + ).toEqual({ status: "completed" }) + + expect(requests).toHaveLength(1) + expect(requests[0]?.promptCacheKey).toBe(rootID) + }), +) + +it.effect("keeps session context hooks away from compaction requests", () => + Effect.gen(function* () { + requests = [] + const compaction = yield* SessionCompaction.Service + // Context hooks shape the agent conversation; compaction is not part of it, + // so it opts out and the transcript passes through unchanged. + const hooks = yield* PluginHooks.Service + yield* hooks.register("session", "context", (event) => + Effect.sync(() => { + event.system.push(SystemPart.make("Injected conversation context")) + }), + ) + const session = yield* insertSession(Session.ID.make("ses_hook_compaction")) + expect( + yield* compaction.compactManual({ + session, + messages: [ + { + id: SessionMessage.ID.create(), + type: "user", + text: "Summarize this conversation.", + time: { created: DateTime.makeUnsafe(0) }, + }, + ], + inputID: SessionMessage.ID.make("msg_hook_compaction"), + }), + ).toEqual({ status: "completed" }) + + expect(requests).toHaveLength(1) + expect(requests[0]?.system).toEqual([]) + }), +) diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index 2c6800e4795..6caae49de7a 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -1,5 +1,5 @@ import { expect } from "bun:test" -import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai" +import { LLMClient, LLMEvent, LanguageModel, SystemPart, type LLMRequest } from "@opencode-ai/ai" import { OpenAIChat } from "@opencode-ai/ai/protocols" import { Agent } from "@opencode-ai/core/agent" import { Database } from "@opencode-ai/core/database/database" @@ -14,6 +14,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { SessionTitle } from "@opencode-ai/core/session/title" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { Session } from "@opencode-ai/core/session" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" @@ -78,7 +79,15 @@ const models = Layer.mock(SessionRunnerModel.Service)({ }) const it = testEffect( AppNodeBuilder.build( - LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Agent.node, SessionTitle.node]), + LayerNode.group([ + Database.node, + Bus.node, + SessionProjector.node, + SessionStore.node, + Agent.node, + PluginHooks.node, + SessionTitle.node, + ]), [ [llmClient, client], [SessionRunnerModel.node, models], @@ -155,6 +164,9 @@ it.effect("generates a title from the sole user message and renames the session" "x-opencode-session": sessionID, "x-opencode-client": "opencode", }) + expect(requests[0]?.promptCacheKey).toBe(sessionID) + expect(requests[0]?.tools).toEqual([]) + expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator."]) expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build") const renamed = yield* store.get(sessionID) expect(renamed?.title).toBe("Generated Title") @@ -323,6 +335,38 @@ it.effect("retries after a failed title request", () => }), ) +it.effect("keeps session context hooks away from title requests", () => + Effect.gen(function* () { + requests = [] + titleStream = successfulTitle + const agentService = yield* Agent.Service + yield* agentService.transform((editor) => { + editor.update(Agent.ID.make("title"), (agent) => { + agent.mode = "primary" + agent.hidden = true + agent.system = "You are a title generator." + }) + }) + // Context hooks shape the agent conversation; title generation is not part of + // it, so it opts out and the transcript passes through unchanged. + const hooks = yield* PluginHooks.Service + yield* hooks.register("session", "context", (event) => + Effect.sync(() => { + event.system.push(SystemPart.make("Keep titles in sentence case.")) + }), + ) + const sessionID = Session.ID.make("ses_title_context_hook") + yield* insertSession(sessionID) + yield* prompt(sessionID, "Hook this title request") + + const title = yield* SessionTitle.Service + yield* title.generateForFirstPrompt(sessionID) + + expect(requests).toHaveLength(1) + expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator."]) + }), +) + it.effect("preserves a manual rename completed while generation is in flight", () => Effect.gen(function* () { requests = []