Browse Source

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

Kit Langton 6 days ago
parent
commit
ccb0b320e1

+ 15 - 10
packages/opencode/src/session/prompt.ts

@@ -102,6 +102,8 @@ export namespace SessionPrompt {
       const instruction = yield* Instruction.Service
       const state = yield* SessionRunState.Service
       const revert = yield* SessionRevert.Service
+      const sys = yield* SystemPrompt.Service
+      const llm = yield* LLM.Service
 
       const run = {
         promise: <A, E>(effect: Effect.Effect<A, E>) =>
@@ -180,21 +182,24 @@ export namespace SessionPrompt {
         const msgs = onlySubtasks
           ? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
           : yield* MessageV2.toModelMessagesEffect(context, mdl)
-        const text = yield* Effect.promise(async (signal) => {
-          const result = await LLM.stream({
+        const text = yield* llm
+          .stream({
             agent: ag,
             user: firstInfo,
             system: [],
             small: true,
             tools: {},
             model: mdl,
-            abort: signal,
             sessionID: input.session.id,
             retries: 2,
             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
           .replace(/<think>[\s\S]*?<\/think>\s*/g, "")
           .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 })
 
               const [skills, env, instructions, modelMsgs] = yield* Effect.all([
-                Effect.promise(() => SystemPrompt.skills(agent)),
-                Effect.promise(() => SystemPrompt.environment(model)),
+                sys.skills(agent),
+                Effect.sync(() => sys.environment(model)),
                 instruction.system().pipe(Effect.orDie),
                 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(Session.defaultLayer),
       Layer.provide(SessionRevert.defaultLayer),
-      Layer.provide(Agent.defaultLayer),
-      Layer.provide(Bus.layer),
-      Layer.provide(CrossSpawnSpawner.defaultLayer),
+      Layer.provide(
+        Layer.mergeAll(Agent.defaultLayer, SystemPrompt.defaultLayer, LLM.defaultLayer, Bus.layer, CrossSpawnSpawner.defaultLayer),
+      ),
     ),
   )
   const { runPromise } = makeRuntime(Service, defaultLayer)

+ 45 - 37
packages/opencode/src/session/system.ts

@@ -1,4 +1,4 @@
-import { Ripgrep } from "../file/ripgrep"
+import { Context, Effect, Layer } from "effect"
 
 import { Instance } from "../project/instance"
 
@@ -33,44 +33,52 @@ export namespace SystemPrompt {
     return [PROMPT_DEFAULT]
   }
 
-  export async function environment(model: Provider.Model) {
-    const project = Instance.project
-    return [
-      [
-        `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 interface Interface {
+    readonly environment: (model: Provider.Model) => string[]
+    readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
   }
 
-  export async function skills(agent: Agent.Info) {
-    if (Permission.disabled(["skill"], agent.permission).has("skill")) return
+  export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
 
-    const list = await Skill.available(agent)
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const skill = yield* Skill.Service
 
-    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")
-  }
+      return Service.of({
+        environment(model) {
+          const project = Instance.project
+          return [
+            [
+              `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>`,
+            ].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))
 }

+ 2 - 0
packages/opencode/test/session/prompt-effect.test.ts

@@ -31,6 +31,7 @@ import { SessionRunState } from "../../src/session/run-state"
 import { MessageID, PartID, SessionID } from "../../src/session/schema"
 import { SessionStatus } from "../../src/session/status"
 import { Skill } from "../../src/skill"
+import { SystemPrompt } from "../../src/session/system"
 import { Shell } from "../../src/shell/shell"
 import { Snapshot } from "../../src/snapshot"
 import { ToolRegistry } from "../../src/tool/registry"
@@ -193,6 +194,7 @@ function makeHttp() {
       Layer.provideMerge(registry),
       Layer.provideMerge(trunc),
       Layer.provide(Instruction.defaultLayer),
+      Layer.provide(SystemPrompt.defaultLayer),
       Layer.provideMerge(deps),
     ),
   )

+ 2 - 0
packages/opencode/test/session/snapshot-tool-race.test.ts

@@ -41,6 +41,7 @@ import { Plugin } from "../../src/plugin"
 import { Provider as ProviderSvc } from "../../src/provider/provider"
 import { Question } from "../../src/question"
 import { Skill } from "../../src/skill"
+import { SystemPrompt } from "../../src/session/system"
 import { Todo } from "../../src/session/todo"
 import { SessionCompaction } from "../../src/session/compaction"
 import { Instruction } from "../../src/session/instruction"
@@ -157,6 +158,7 @@ function makeHttp() {
       Layer.provideMerge(registry),
       Layer.provideMerge(trunc),
       Layer.provide(Instruction.defaultLayer),
+      Layer.provide(SystemPrompt.defaultLayer),
       Layer.provideMerge(deps),
     ),
   )

+ 8 - 2
packages/opencode/test/session/system.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import path from "path"
+import { Effect } from "effect"
 import { Agent } from "../../src/agent/agent"
 import { Instance } from "../../src/project/instance"
 import { SystemPrompt } from "../../src/session/system"
@@ -38,8 +39,13 @@ description: ${description}
         directory: tmp.path,
         fn: async () => {
           const build = await Agent.get("build")
-          const first = await SystemPrompt.skills(build!)
-          const second = await SystemPrompt.skills(build!)
+          const runSkills = Effect.gen(function* () {
+            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)