refactor(core): select system prompts through plugins (#37181)

This commit is contained in:
Aiden Cline 2026-07-15 18:21:32 -05:00 committed by GitHub
commit 720f062cd0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 447 additions and 263 deletions

File diff suppressed because one or more lines are too long

View file

@ -21,6 +21,7 @@ export function host(overrides: Overrides = {}): PluginContext {
return {
options: {},
agent: overrides.agent ?? {
get: () => Effect.die("unused agent.get"),
list: () => Effect.die("unused agent.list"),
transform: () => Effect.die("unused agent.transform"),
reload: () => Effect.die("unused agent.reload"),
@ -34,6 +35,7 @@ export function host(overrides: Overrides = {}): PluginContext {
get: () => Effect.die("unused catalog.provider.get"),
},
model: {
get: () => Effect.die("unused catalog.model.get"),
list: () => Effect.die("unused catalog.model.list"),
default: () => Effect.die("unused catalog.model.default"),
},
@ -102,6 +104,7 @@ export function host(overrides: Overrides = {}): PluginContext {
export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
return {
get: (id) => agent.get(AgentV2.ID.make(id)).pipe(Effect.map((value) => value && agentInfo(value))),
list: () => Effect.die("unused agent.list"),
reload: agent.reload,
transform: (callback) =>
@ -132,6 +135,10 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"
get: () => Effect.die("unused catalog.provider.get"),
},
model: {
get: (providerID, modelID) =>
catalog.model
.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID))
.pipe(Effect.map((value) => value && modelInfo(value))),
list: () => Effect.die("unused catalog.model.list"),
default: () => Effect.die("unused catalog.model.default"),
},

View file

@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginHost } from "@opencode-ai/core/plugin/host"
@ -9,6 +11,7 @@ import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Plugin } from "@opencode-ai/plugin/v2"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { Model } from "@opencode-ai/schema/model"
@ -48,6 +51,37 @@ describe("fromPromise", () => {
}),
)
it.effect("forwards direct agent and model reads", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
yield* agents.transform((draft) =>
draft.update(AgentV2.ID.make("reviewer"), (agent) => {
agent.description = "Reviews code"
}),
)
yield* catalog.transform((draft) =>
draft.model.update(ProviderV2.ID.make("test"), ModelV2.ID.make("alias"), (model) => {
model.modelID = ModelV2.ID.make("gpt-5")
}),
)
yield* PluginPromise.fromPromise(
Plugin.define({
id: "promise-direct-reads",
setup: async (ctx) => {
expect(await ctx.agent.get("reviewer")).toMatchObject({ description: "Reviews code" })
expect(await ctx.agent.get("missing")).toBeUndefined()
expect(await ctx.catalog.model.get("test", "alias")).toMatchObject({ modelID: "gpt-5" })
expect(await ctx.catalog.model.get("test", "missing")).toBeUndefined()
},
}),
).effect(host)
}),
)
it.effect("loads a promise plugin and registers a transform hook", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service

View file

@ -0,0 +1,161 @@
import { describe, expect, test } from "bun:test"
import { SystemPart } from "@opencode-ai/ai"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { SessionV2 } from "@opencode-ai/core/session"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
import PROMPT_META from "../../src/plugin/system-prompt/meta.txt"
import PROMPT_DEFAULT from "../../src/session/runner/prompt/base.txt"
const it = testEffect(PluginTestLayer)
const fallback = PROMPT_DEFAULT
const makeHost = Effect.gen(function* () {
const plugins = yield* PluginV2.Service
return yield* PluginHost.make(plugins)
})
const context = (id: string, system = fallback): SessionHooks["context"] => ({
sessionID: SessionV2.ID.make("ses_system_prompt"),
agent: AgentV2.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make(id) }),
system: [SystemPart.make(system)],
messages: [],
tools: {},
})
describe("SystemPromptPlugin", () => {
test("uses V2 vocabulary in the Meta prompt", () => {
expect(PROMPT_META).toContain("webfetch tool")
expect(PROMPT_META).toContain("subagent tool")
expect(PROMPT_META).toContain("shell tool")
expect(PROMPT_META).toContain("read for reading files")
expect(PROMPT_META).toContain("edit for editing")
expect(PROMPT_META).toContain("write for creating files")
expect(PROMPT_META).toContain("https://v2.opencode.ai/llms.txt")
expect(PROMPT_META).not.toMatch(
/TodoWrite|Task tool|WebFetch|\bBash\b|Read for reading files|Edit for editing|Write for creating files|https:\/\/opencode\.ai\/docs/,
)
})
test("uses granular IDs with a common prefix", () => {
expect(SystemPromptPlugin.Plugins.map((plugin) => plugin.id)).toEqual([
"opencode.system-prompt.openai",
"opencode.system-prompt.google",
"opencode.system-prompt.anthropic",
"opencode.system-prompt.kimi",
"opencode.system-prompt.arcee",
"opencode.system-prompt.meta",
])
})
it.effect("selects model-lab prompts through session context hooks", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const pluginHost = yield* makeHost
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
discard: true,
})
const cases = [
["gpt-5", "You are OpenCode, You and the user share the same workspace"],
["gpt-4.1", "You are OpenCode, You and the user share the same workspace"],
["o3", "You are OpenCode, You and the user share the same workspace"],
["gpt-5-codex", "## Editing constraints"],
["gemini-2.5-pro", "# Core Mandates"],
["claude-sonnet-4", "# Professional objectivity"],
["kimi-k2", "# Prompt and Tool Use"],
["trinity", "what command should I run to list files"],
["meta/muse-spark-1.1", "OpenCode powered by Meta Muse Spark"],
["llama-3.3", "You are opencode, an interactive CLI tool"],
] as const
yield* Effect.forEach(
cases,
([id, expected]) => {
const event = context(id)
return hooks
.trigger("session", "context", event)
.pipe(Effect.tap(() => Effect.sync(() => expect(event.system[0]?.text).toContain(expected))))
},
{ discard: true },
)
}),
)
it.effect("preserves an explicit agent system prompt", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const hooks = yield* PluginHooks.Service
yield* agents.transform((draft) =>
draft.update(AgentV2.ID.make("build"), (agent) => {
agent.system = "Custom agent prompt"
}),
)
const pluginHost = yield* makeHost
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
discard: true,
})
const event = context("gpt-5", "Custom agent prompt")
yield* hooks.trigger("session", "context", event)
expect(event.system.map((part) => part.text)).toEqual(["Custom agent prompt"])
}),
)
it.effect("allows one model-lab prompt plugin to be enabled independently", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const pluginHost = yield* makeHost
yield* SystemPromptPlugin.GooglePlugin.effect(pluginHost)
const gemini = context("gemini-2.5-pro")
const claude = context("claude-sonnet-4")
yield* hooks.trigger("session", "context", gemini)
yield* hooks.trigger("session", "context", claude)
expect(gemini.system[0]?.text).toContain("# Core Mandates")
expect(claude.system[0]?.text).toBe(fallback)
}),
)
it.effect("selects against the catalog model ID instead of its alias", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
const pluginHost = yield* makeHost
yield* catalog.transform((draft) => {
draft.model.update(Provider.ID.make("test"), Model.ID.make("openai-alias"), (model) => {
model.modelID = Model.ID.make("gpt-5")
})
draft.model.update(Provider.ID.make("test"), Model.ID.make("gpt-5-alias"), (model) => {
model.modelID = Model.ID.make("custom-model")
})
draft.model.update(Provider.ID.make("test"), Model.ID.make("codex-family-alias"), (model) => {
model.modelID = Model.ID.make("custom-deployment")
model.family = Model.Family.make("gpt-codex")
})
})
yield* SystemPromptPlugin.OpenAIPlugin.effect(pluginHost)
const physicalOpenAI = context("openai-alias")
const physicalCustom = context("gpt-5-alias")
const familyOpenAI = context("codex-family-alias")
yield* hooks.trigger("session", "context", physicalOpenAI)
yield* hooks.trigger("session", "context", physicalCustom)
yield* hooks.trigger("session", "context", familyOpenAI)
expect(physicalOpenAI.system[0]?.text).toContain("You are OpenCode, You and the user share the same workspace")
expect(physicalCustom.system[0]?.text).toBe(fallback)
expect(familyOpenAI.system[0]?.text).toContain("## Editing constraints")
}),
)
})

View file

@ -1,6 +1,7 @@
import { HttpRecorder } from "@opencode-ai/http-recorder"
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
import { Catalog } from "@opencode-ai/core/catalog"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
@ -36,11 +37,14 @@ import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
import path from "node:path"
import { testEffect } from "./lib/effect"
import { agentHost, catalogHost, host } from "./plugin/host"
const cassetteName = "session-runner/openai-chat-streams-text"
const cassetteDirectory = path.resolve(import.meta.dir, "fixtures/recordings")
@ -77,6 +81,20 @@ const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Ef
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.succeed(undefined),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
},
model: {
get: () => Effect.succeed(undefined),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(undefined),
small: () => Effect.succeed(undefined),
},
})
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, client],
@ -116,6 +134,8 @@ const it = testEffect(
SessionProjector.node,
SessionStore.node,
AgentV2.node,
Catalog.node,
PluginHooks.node,
ToolRegistry.node,
SessionRunnerModel.node,
InstructionBuiltIns.node,
@ -130,6 +150,7 @@ const it = testEffect(
[
[LayerNodePlatform.llmClient, client],
[PermissionV2.node, permission],
[Catalog.node, promptCatalog],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[SessionRunnerModel.node, models],
[InstructionBuiltIns.node, systemContext],
@ -150,11 +171,19 @@ describe("SessionRunnerLLM recorded", () => {
it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
yield* agents.transform((draft) =>
draft.update(AgentV2.ID.make("build"), (agent) => {
agent.mode = "primary"
}),
)
const pluginHost = host({
agent: agentHost(agents),
catalog: catalogHost(catalog),
session: { hook: (name, callback) => hooks.register("session", name, callback) },
})
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)

View file

@ -1,21 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Model } from "@opencode-ai/ai"
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
const prompt = (id: string) =>
SessionRunnerSystemPrompt.provider(Model.make({ id, provider: "test", route: OpenAIChat.route }))
describe("SessionRunnerSystemPrompt", () => {
test("selects the legacy provider-family prompts from the model id", () => {
expect(prompt("gpt-5")).toContain("You are OpenCode, You and the user share the same workspace")
expect(prompt("gpt-4.1")).toContain("THE PROBLEM CAN NOT BE SOLVED WITHOUT EXTENSIVE INTERNET RESEARCH")
expect(prompt("o3")).toContain("THE PROBLEM CAN NOT BE SOLVED WITHOUT EXTENSIVE INTERNET RESEARCH")
expect(prompt("gpt-5-codex")).toContain("## Editing constraints")
expect(prompt("gemini-2.5-pro")).toContain("# Core Mandates")
expect(prompt("claude-sonnet-4")).toContain("# Professional objectivity")
expect(prompt("kimi-k2")).toContain("# Prompt and Tool Use")
expect(prompt("trinity")).toContain("what command should I run to list files")
expect(prompt("llama-3.3")).toContain("You are opencode, an interactive CLI tool")
})
})

View file

@ -15,6 +15,7 @@ import {
type LLMRequest,
} from "@opencode-ai/ai"
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
import { Catalog } from "@opencode-ai/core/catalog"
import { Database } from "@opencode-ai/core/database/database"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@ -41,10 +42,10 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator
import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { QuestionTool } from "@opencode-ai/core/tool/question"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { AgentV2 } from "@opencode-ai/core/agent"
@ -72,6 +73,8 @@ import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, S
import { TestClock } from "effect/testing"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
import { agentHost, catalogHost, host } from "./plugin/host"
import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt"
const requests: LLMRequest[] = []
let response: LLMEvent[] = []
@ -137,7 +140,7 @@ const reply = {
],
}
const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
const defaultSystem = SessionRunnerSystemPrompt.provider(model)
const defaultSystem = PROMPT_DEFAULT
const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route })
const compactModel = Model.make({
id: "compact",
@ -358,6 +361,20 @@ const pluginSupervisor = Layer.succeed(
flush: Effect.suspend(() => pluginFlushHook),
}),
)
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.succeed(undefined),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
},
model: {
get: () => Effect.succeed(undefined),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(undefined),
small: () => Effect.succeed(undefined),
},
})
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, client],
@ -398,6 +415,7 @@ const it = testEffect(
SessionProjector.node,
SessionStore.node,
AgentV2.node,
Catalog.node,
ToolRegistry.node,
ToolRegistry.toolsNode,
PluginHooks.node,
@ -417,6 +435,7 @@ const it = testEffect(
[
[LayerNodePlatform.llmClient, client],
[PermissionV2.node, permission],
[Catalog.node, promptCatalog],
[SessionRunnerModel.node, models],
[InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext],
@ -455,6 +474,17 @@ const insertSession = (id: SessionV2.ID) =>
const setup = Effect.gen(function* () {
const { db } = yield* Database.Service
const agents = yield* AgentV2.Service
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
const pluginHost = host({
agent: agentHost(agents),
catalog: catalogHost(catalog),
session: { hook: (name, callback) => hooks.register("session", name, callback) },
})
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
discard: true,
})
requests.length = 0
authorizations.length = 0
executions.length = 0
@ -478,7 +508,6 @@ const setup = Effect.gen(function* () {
toolExecutionsReady = 5
activeToolExecutions = 0
maxActiveToolExecutions = 0
const agents = yield* AgentV2.Service
yield* agents.transform((draft) =>
draft.update(AgentV2.ID.make("build"), (agent) => {
agent.mode = "primary"