mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-02 15:06:21 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63b7d60e90 |
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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 }>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user