Compare commits

...
Author SHA1 Message Date
Aiden Cline 63b7d60e90 feat(plugin): expose session context purpose 2026-09-02 02:26:53 -05:00
19 changed files with 91 additions and 11 deletions
+1
View File
@@ -56,6 +56,7 @@ export const Plugin = define({
// Compaction and committed reverts can strip reminders while the session's agent stays
// put. Reconcile per request, appending near the tail so the cached prefix stays warm.
yield* ctx.session.hook("context", (event) => {
if (event.purpose === "title") return Effect.void
const reminder = lastReminder(event.messages, enterReminder)
const missing = event.agent === plan && reminder !== enterReminder
const stale = event.agent !== plan && reminder === enterReminder
@@ -35,6 +35,7 @@ function make(id: string, select: (modelID: string) => string | undefined) {
effect: Effect.fn(`SystemPromptPlugin.${id}`)(function* (ctx) {
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
if (event.purpose === "title") return
if ((yield* ctx.agent.get({ agentID: event.agent })).data.system) return
const system = event.system[0]
if (!system) return
+1
View File
@@ -56,6 +56,7 @@ export const Plugin = define({
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
if (event.purpose === "title") return
const active = sessions.get(event.sessionID)
const settings = yield* loadSettings()
if (!settings) {
+1
View File
@@ -382,6 +382,7 @@ export const layer = Layer.effect(
messages: history.messages,
})
const prepared = yield* input.prepare({
purpose: "compaction",
scope: {
session: context.session,
agentID: Agent.ID.make("compaction"),
+1
View File
@@ -36,6 +36,7 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
messages: history.messages,
})
const prepared = yield* context.prepare({
purpose: "generate",
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
transcript: {
system: transcript.system,
+3 -6
View File
@@ -57,6 +57,7 @@ export interface Prepared {
}
interface PrepareInput {
readonly purpose: PluginHooks.Domains["session"]["context"]["purpose"]
readonly scope: {
readonly session: SessionSchema.Info
readonly agentID: Agent.ID
@@ -71,11 +72,6 @@ interface PrepareInput {
readonly messages: Array<Message>
}
readonly toolChoice?: LLM.RequestInput["toolChoice"]
/**
* Session context hooks shape the agent conversation. Standalone requests
* such as titles opt out; compaction uses the selected Session context.
*/
readonly contextHooks?: false
/** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */
readonly webSocket?: "session"
}
@@ -303,13 +299,14 @@ export const layer = Layer.effect(
sessionID: session.id,
agent: input.scope.contextAgentID ?? input.scope.agentID,
model: resolved.ref,
purpose: input.purpose,
system: input.transcript.system,
messages: input.transcript.messages,
tools: definitions,
generation: {},
providerOptions: {},
}
if (input.contextHooks !== false) yield* hooks.trigger("session", "context", context)
yield* hooks.trigger("session", "context", context)
// 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.
+1
View File
@@ -214,6 +214,7 @@ const layer = Layer.effect(
messages: loaded.messages,
})
const prepared = yield* context.prepare({
purpose: "session",
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript: {
system: transcript.system,
+1 -1
View File
@@ -64,12 +64,12 @@ export const layer = Layer.effect(
: Effect.void,
)
const prepared = yield* context.prepare({
purpose: "title",
scope: { session: input.session, agentID: input.agent.id, model: input.model },
transcript: {
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
messages: [Message.user(input.text)],
},
contextHooks: false,
})
yield* llm.stream(prepared.request, prepared.options).pipe(
Stream.runForEach((event) => {
+17 -1
View File
@@ -117,10 +117,15 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
return { persisted, contextHook, toolHook, files: Environment.makeFiles(driver), planAgent }
})
const request = (agent: Agent.ID, messages: Array<Message>): SessionContext => ({
const request = (
agent: Agent.ID,
messages: Array<Message>,
purpose: SessionContext["purpose"] = "session",
): SessionContext => ({
sessionID,
agent,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test") },
purpose,
system: [],
messages,
tools: {},
@@ -242,6 +247,17 @@ describe("plan plugin reminders", () => {
expect(persisted).toHaveLength(1)
}),
)
it.effect("does not reconcile reminders for auxiliary requests", () =>
Effect.gen(function* () {
const { enter } = yield* reminders
const { persisted, contextHook } = yield* run()
const messages = [Message.user(enter)]
yield* contextHook(request(build, messages, "title"))
expect(messages).toHaveLength(1)
expect(persisted).toHaveLength(0)
}),
)
})
describe("plan plugin mutations", () => {
@@ -226,6 +226,7 @@ describe("OpenAIPlugin", () => {
const program = Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service
return yield* requests.prepare({
purpose: "session",
scope: {
session: Session.Info.make({
id: sessionID,
@@ -25,10 +25,15 @@ const makeHost = Effect.gen(function* () {
return yield* PluginHost.make(plugins)
})
const context = (id: string, system = fallback): SessionHooks["context"] => ({
const context = (
id: string,
system = fallback,
purpose: SessionHooks["context"]["purpose"] = "session",
): SessionHooks["context"] => ({
sessionID: Session.ID.make("ses_system_prompt"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make(id) }),
purpose,
system: [SystemPart.make(system)],
messages: [],
tools: {},
@@ -145,6 +150,19 @@ describe("SystemPromptPlugin", () => {
}),
)
it.effect("preserves title request prompts", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const pluginHost = yield* makeHost
yield* SystemPromptPlugin.OpenAIPlugin.effect(pluginHost)
const title = context("gpt-5", fallback, "title")
yield* hooks.trigger("session", "context", title)
expect(title.system[0]?.text).toBe(fallback)
}),
)
it.effect("skips the hook when agent lookup fails", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
@@ -7,6 +7,7 @@ import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { EventTable } from "@opencode-ai/core/event/sql"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
@@ -83,6 +84,7 @@ const it = testEffect(
Bus.node,
SessionProjector.node,
SessionStore.node,
PluginHooks.node,
SessionCompaction.node,
SessionModelRequest.node,
]),
@@ -353,6 +355,13 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
time: { created: DateTime.makeUnsafe(2) },
}),
]
const purposes: string[] = []
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
purposes.push(event.purpose)
}),
)
const delta = yield* bus
.subscribe(SessionEvent.Compaction.Delta)
@@ -372,6 +381,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
])
expect(requests).toHaveLength(1)
expect(purposes).toEqual(["compaction"])
expect(requests[0]?.promptCacheKey).toBe(sessionID)
expect(requests[0]?.http?.headers).toEqual({
"x-session-affinity": sessionID,
@@ -17,6 +17,7 @@ import { Location } from "@opencode-ai/core/location"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { ID } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Provider } from "@opencode-ai/core/provider"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -131,6 +132,7 @@ const it = testEffect(
SessionProjector.node,
SessionStore.node,
Agent.node,
PluginHooks.node,
InstructionBuiltIns.node,
SessionContext.node,
llmClient,
@@ -302,12 +304,20 @@ it.effect(
})
instruction = "Changed context"
const before = yield* durableState(db, sessionID)
const purposes: string[] = []
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
purposes.push(event.purpose)
}),
)
const result = yield* SessionGenerate.generate({ session, prompt: "Summarize privately" }).pipe(
Effect.provideService(Instance.Service, instances),
)
expect(result).toBe("Transient answer")
expect(purposes).toEqual(["generate"])
expect(requests).toHaveLength(1)
expect(requests[0]?.model).toBe(model)
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
+7
View File
@@ -272,7 +272,13 @@ it.effect("falls back to the primary model when the small model fails", () =>
yield* prompt(sessionID, "Fall back when title generation fails")
const attempted: Model.Ref[] = []
const purposes: string[] = []
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
purposes.push(event.purpose)
}),
)
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
attempted.push(event.model)
@@ -283,6 +289,7 @@ it.effect("falls back to the primary model when the small model fails", () =>
yield* title.generate(sessionID)
expect(requests.map((request) => String(request.model.id))).toEqual(["title-small", "title-model"])
expect(purposes).toEqual(["title", "title"])
expect(attempted.map((model) => String(model.variant))).toEqual(["low", "high"])
const store = yield* SessionStore.Service
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
+3 -1
View File
@@ -94,10 +94,12 @@ await ctx.aisdk.hook("language", (event) => {
})
```
Session context is mutable immediately before provider dispatch:
Session context is mutable immediately before provider dispatch. The purpose identifies whether the request belongs to
the session loop, direct generation, title generation, or compaction:
```ts
await ctx.session.hook("context", (event) => {
if (event.purpose !== "session") return
event.tools.read.description = "Read a file using narrow line ranges."
delete event.tools.write
})
+3 -1
View File
@@ -88,12 +88,14 @@ yield *
Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks.
Session context is mutable immediately before provider dispatch:
Session context is mutable immediately before provider dispatch. The purpose identifies whether the request belongs to
the session loop, direct generation, title generation, or compaction:
```ts
yield *
ctx.session.hook("context", (event) =>
Effect.sync(() => {
if (event.purpose !== "session") return
event.tools.read.description = "Read a file using narrow line ranges."
delete event.tools.write
}),
+4
View File
@@ -18,10 +18,14 @@ export interface SessionPrompt {
delivery: SessionInbox.Delivery
}
export type SessionContextPurpose = "session" | "generate" | "title" | "compaction"
export interface SessionContext {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
/** Identifies the operation preparing this model context. */
readonly purpose: SessionContextPurpose
system: Array<SystemPart>
messages: Array<Message>
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
+4
View File
@@ -18,10 +18,14 @@ export interface SessionPrompt {
delivery: SessionInbox.Delivery
}
export type SessionContextPurpose = "session" | "generate" | "title" | "compaction"
export interface SessionContext {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
/** Identifies the operation preparing this model context. */
readonly purpose: SessionContextPurpose
system: Array<SystemPart>
messages: Array<Message>
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
@@ -41,6 +41,7 @@ it.live(
const boots: Session.ID[] = []
const executed: Session.ID[] = []
const commands: Session.ID[] = []
const purposes: string[] = []
const llm = yield* TestLLM.Test.pipe(Effect.provide(TestLLM.testLayer()))
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "instance-model", provider: "test", route: OpenAIChat.route }),
@@ -94,6 +95,7 @@ it.live(
)
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
purposes.push(event.purpose)
event.generation.temperature = config.temperature
}),
)
@@ -220,6 +222,7 @@ it.live(
])
}
expect(commands).toEqual([first.id, second.id])
expect(purposes).toEqual(["session", "session", "session", "session", "generate", "generate"])
expect(
(yield* llm.requests()).map((request) => ({
temperature: request.generation?.temperature,