refactor(session): make SystemPrompt a proper Effect Service (#21992)

This commit is contained in:
Kit Langton
2026-04-11 12:52:35 -04:00
committed by GitHub
parent 5ee7edaf9e
commit ccb0b320e1
5 changed files with 72 additions and 49 deletions
+15 -10
View File
@@ -102,6 +102,8 @@ export namespace SessionPrompt {
const instruction = yield* Instruction.Service const instruction = yield* Instruction.Service
const state = yield* SessionRunState.Service const state = yield* SessionRunState.Service
const revert = yield* SessionRevert.Service const revert = yield* SessionRevert.Service
const sys = yield* SystemPrompt.Service
const llm = yield* LLM.Service
const run = { const run = {
promise: <A, E>(effect: Effect.Effect<A, E>) => promise: <A, E>(effect: Effect.Effect<A, E>) =>
@@ -180,21 +182,24 @@ export namespace SessionPrompt {
const msgs = onlySubtasks const msgs = onlySubtasks
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }] ? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
: yield* MessageV2.toModelMessagesEffect(context, mdl) : yield* MessageV2.toModelMessagesEffect(context, mdl)
const text = yield* Effect.promise(async (signal) => { const text = yield* llm
const result = await LLM.stream({ .stream({
agent: ag, agent: ag,
user: firstInfo, user: firstInfo,
system: [], system: [],
small: true, small: true,
tools: {}, tools: {},
model: mdl, model: mdl,
abort: signal,
sessionID: input.session.id, sessionID: input.session.id,
retries: 2, retries: 2,
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs], messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
}) })
return result.text .pipe(
}) Stream.filter((e): e is Extract<LLM.Event, { type: "text-delta" }> => e.type === "text-delta"),
Stream.map((e) => e.text),
Stream.mkString,
Effect.orDie,
)
const cleaned = text const cleaned = text
.replace(/<think>[\s\S]*?<\/think>\s*/g, "") .replace(/<think>[\s\S]*?<\/think>\s*/g, "")
.split("\n") .split("\n")
@@ -1462,8 +1467,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const [skills, env, instructions, modelMsgs] = yield* Effect.all([ const [skills, env, instructions, modelMsgs] = yield* Effect.all([
Effect.promise(() => SystemPrompt.skills(agent)), sys.skills(agent),
Effect.promise(() => SystemPrompt.environment(model)), Effect.sync(() => sys.environment(model)),
instruction.system().pipe(Effect.orDie), instruction.system().pipe(Effect.orDie),
MessageV2.toModelMessagesEffect(msgs, model), MessageV2.toModelMessagesEffect(msgs, model),
]) ])
@@ -1687,9 +1692,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
Layer.provide(Plugin.defaultLayer), Layer.provide(Plugin.defaultLayer),
Layer.provide(Session.defaultLayer), Layer.provide(Session.defaultLayer),
Layer.provide(SessionRevert.defaultLayer), Layer.provide(SessionRevert.defaultLayer),
Layer.provide(Agent.defaultLayer), Layer.provide(
Layer.provide(Bus.layer), Layer.mergeAll(Agent.defaultLayer, SystemPrompt.defaultLayer, LLM.defaultLayer, Bus.layer, CrossSpawnSpawner.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer), ),
), ),
) )
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
+45 -37
View File
@@ -1,4 +1,4 @@
import { Ripgrep } from "../file/ripgrep" import { Context, Effect, Layer } from "effect"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
@@ -33,44 +33,52 @@ export namespace SystemPrompt {
return [PROMPT_DEFAULT] return [PROMPT_DEFAULT]
} }
export async function environment(model: Provider.Model) { export interface Interface {
const project = Instance.project readonly environment: (model: Provider.Model) => string[]
return [ readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
[
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
`Here is some useful information about the environment you are running in:`,
`<env>`,
` Working directory: ${Instance.directory}`,
` Workspace root folder: ${Instance.worktree}`,
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
`</env>`,
`<directories>`,
` ${
project.vcs === "git" && false
? await Ripgrep.tree({
cwd: Instance.directory,
limit: 50,
})
: ""
}`,
`</directories>`,
].join("\n"),
]
} }
export async function skills(agent: Agent.Info) { export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
const list = await Skill.available(agent) export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const skill = yield* Skill.Service
return [ return Service.of({
"Skills provide specialized instructions and workflows for specific tasks.", environment(model) {
"Use the skill tool to load a skill when a task matches its description.", const project = Instance.project
// the agents seem to ingest the information about skills a bit better if we present a more verbose return [
// version of them here and a less verbose version in tool description, rather than vice versa. [
Skill.fmt(list, { verbose: true }), `You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
].join("\n") `Here is some useful information about the environment you are running in:`,
} `<env>`,
` Working directory: ${Instance.directory}`,
` Workspace root folder: ${Instance.worktree}`,
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
`</env>`,
].join("\n"),
]
},
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
const list = yield* skill.available(agent)
return [
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
// the agents seem to ingest the information about skills a bit better if we present a more verbose
// version of them here and a less verbose version in tool description, rather than vice versa.
Skill.fmt(list, { verbose: true }),
].join("\n")
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer))
} }
@@ -31,6 +31,7 @@ import { SessionRunState } from "../../src/session/run-state"
import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status" import { SessionStatus } from "../../src/session/status"
import { Skill } from "../../src/skill" import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system"
import { Shell } from "../../src/shell/shell" import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot" import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "../../src/tool/registry" import { ToolRegistry } from "../../src/tool/registry"
@@ -193,6 +194,7 @@ function makeHttp() {
Layer.provideMerge(registry), Layer.provideMerge(registry),
Layer.provideMerge(trunc), Layer.provideMerge(trunc),
Layer.provide(Instruction.defaultLayer), Layer.provide(Instruction.defaultLayer),
Layer.provide(SystemPrompt.defaultLayer),
Layer.provideMerge(deps), Layer.provideMerge(deps),
), ),
) )
@@ -41,6 +41,7 @@ import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider" import { Provider as ProviderSvc } from "../../src/provider/provider"
import { Question } from "../../src/question" import { Question } from "../../src/question"
import { Skill } from "../../src/skill" import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system"
import { Todo } from "../../src/session/todo" import { Todo } from "../../src/session/todo"
import { SessionCompaction } from "../../src/session/compaction" import { SessionCompaction } from "../../src/session/compaction"
import { Instruction } from "../../src/session/instruction" import { Instruction } from "../../src/session/instruction"
@@ -157,6 +158,7 @@ function makeHttp() {
Layer.provideMerge(registry), Layer.provideMerge(registry),
Layer.provideMerge(trunc), Layer.provideMerge(trunc),
Layer.provide(Instruction.defaultLayer), Layer.provide(Instruction.defaultLayer),
Layer.provide(SystemPrompt.defaultLayer),
Layer.provideMerge(deps), Layer.provideMerge(deps),
), ),
) )
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { Effect } from "effect"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { SystemPrompt } from "../../src/session/system" import { SystemPrompt } from "../../src/session/system"
@@ -38,8 +39,13 @@ description: ${description}
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const build = await Agent.get("build") const build = await Agent.get("build")
const first = await SystemPrompt.skills(build!) const runSkills = Effect.gen(function* () {
const second = await SystemPrompt.skills(build!) const svc = yield* SystemPrompt.Service
return yield* svc.skills(build!)
}).pipe(Effect.provide(SystemPrompt.defaultLayer))
const first = await Effect.runPromise(runSkills)
const second = await Effect.runPromise(runSkills)
expect(first).toBe(second) expect(first).toBe(second)