Просмотр исходного кода

refactor(scout): resolve configured reference mentions (#26701)

Shoubhit Dash 3 месяцев назад
Родитель
Сommit
7e997cfba4

+ 0 - 71
packages/opencode/src/agent/agent.ts

@@ -26,7 +26,6 @@ import * as Option from "effect/Option"
 import * as OtelTracer from "@effect/opentelemetry/Tracer"
 import { zod } from "@opencode-ai/core/effect-zod"
 import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
-import { Reference } from "@/reference/reference"
 
 export const Info = Schema.Struct({
   name: Schema.String,
@@ -301,76 +300,6 @@ export const layer = Layer.effect(
           item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
         }
 
-        function referencePrompt(reference: Reference.Resolved) {
-          if (reference.kind === "local") {
-            return [
-              `You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
-              `Local directory: ${reference.path}`,
-              `Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
-              `Return exact absolute file paths for findings whenever possible.`,
-            ].join("\n\n")
-          }
-
-          if (reference.kind === "invalid") {
-            return [
-              `You are configured reference @${reference.name}, but this reference is not usable yet.`,
-              `Configured repository: ${reference.repository}`,
-              `Problem: ${reference.message}`,
-              `Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`,
-            ].join("\n\n")
-          }
-
-          return [
-            `You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
-            `Repository: ${reference.repository}`,
-            ...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
-            `Cached directory: ${reference.path}`,
-            `OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`,
-            `Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`,
-            `Return exact absolute file paths for findings whenever possible.`,
-          ].join("\n\n")
-        }
-
-        function referenceDescription(reference: Reference.Resolved) {
-          if (reference.kind === "local") return `Scout reference for local directory ${reference.path}`
-          if (reference.kind === "git") return `Scout reference for repository ${reference.repository}`
-          return `Invalid Scout reference for repository ${reference.repository}`
-        }
-
-        if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
-          const resolvedReferences = Reference.resolveAll({
-            references: cfg.reference ?? {},
-            directory: ctx.directory,
-            worktree: ctx.worktree,
-          })
-          for (const resolved of resolvedReferences) {
-            if (agents[resolved.name]) continue
-            const localPath = resolved.kind === "invalid" ? undefined : resolved.path
-            agents[resolved.name] = {
-              name: resolved.name,
-              description: referenceDescription(resolved),
-              permission: Permission.merge(
-                agents.scout.permission,
-                Permission.fromConfig({
-                  repo_clone: "deny",
-                  ...(localPath
-                    ? {
-                        external_directory: {
-                          [localPath]: "allow",
-                          [path.join(localPath, "*")]: "allow",
-                        },
-                      }
-                    : {}),
-                }),
-              ),
-              prompt: referencePrompt(resolved),
-              options: { reference: cfg.reference?.[resolved.name], resolved },
-              mode: "subagent",
-              native: false,
-            }
-          }
-        }
-
         // Ensure Truncate.GLOB is allowed unless explicitly configured
         for (const name in agents) {
           const agent = agents[name]

+ 169 - 1
packages/opencode/src/session/prompt.ts

@@ -56,7 +56,8 @@ import { EffectBridge } from "@/effect/bridge"
 import { SyncEvent } from "@/sync"
 import { SessionEvent } from "@/v2/session-event"
 import { Modelv2 } from "@/v2/model"
-import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt"
+import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@/v2/session-prompt"
+import { Reference } from "@/reference/reference"
 import * as DateTime from "effect/DateTime"
 import { eq } from "@/storage/db"
 import * as Database from "@/storage/db"
@@ -81,6 +82,45 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
 const log = Log.create({ service: "session.prompt" })
 const elog = EffectLogger.create({ service: "session.prompt" })
 
+type ReferencePromptMetadata = {
+  name: string
+  kind: "local" | "git" | "invalid"
+  path?: string
+  repository?: string
+  branch?: string
+  target?: string
+  targetPath?: string
+  problem?: string
+  source: { value: string; start: number; end: number }
+}
+
+function stringField(record: Record<string, unknown>, key: string) {
+  return typeof record[key] === "string" ? record[key] : undefined
+}
+
+function referencePromptMetadata(input: unknown): ReferencePromptMetadata | undefined {
+  if (!input || typeof input !== "object" || Array.isArray(input)) return
+  const record = input as Record<string, unknown>
+  const name = stringField(record, "name")
+  const kind = stringField(record, "kind")
+  if (!name || (kind !== "local" && kind !== "git" && kind !== "invalid")) return
+  if (!record.source || typeof record.source !== "object" || Array.isArray(record.source)) return
+  const source = record.source as Record<string, unknown>
+  const value = stringField(source, "value")
+  if (!value || typeof source.start !== "number" || typeof source.end !== "number") return
+  return {
+    name,
+    kind,
+    path: stringField(record, "path"),
+    repository: stringField(record, "repository"),
+    branch: stringField(record, "branch"),
+    target: stringField(record, "target"),
+    targetPath: stringField(record, "targetPath"),
+    problem: stringField(record, "problem"),
+    source: { value, start: source.start, end: source.end },
+  }
+}
+
 export interface Interface {
   readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
   readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
@@ -119,6 +159,7 @@ export const layer = Layer.effect(
     const summary = yield* SessionSummary.Service
     const sys = yield* SystemPrompt.Service
     const llm = yield* LLM.Service
+    const references = yield* Reference.Service
     const sync = yield* SyncEvent.Service
     const runner = Effect.fn("SessionPrompt.runner")(function* () {
       return yield* EffectBridge.make()
@@ -141,12 +182,116 @@ export const layer = Layer.effect(
       const parts: Types.DeepMutable<PromptInput["parts"]> = [{ type: "text", text: template }]
       const files = ConfigMarkdown.files(template)
       const seen = new Set<string>()
+      const mentionSource = (match: RegExpMatchArray) => {
+        const start = match.index ?? 0
+        return { value: match[0], start, end: start + match[0].length }
+      }
+      const referenceTextPart = (input: {
+        reference: Reference.Resolved
+        source: ReturnType<typeof mentionSource>
+        target?: string
+        targetPath?: string
+        problem?: string
+      }): MessageV2.TextPartInput => {
+        const metadata: ReferencePromptMetadata = {
+          name: input.reference.name,
+          kind: input.reference.kind,
+          ...(input.reference.kind === "invalid"
+            ? { repository: input.reference.repository }
+            : { path: input.reference.path }),
+          ...(input.reference.kind === "git"
+            ? { repository: input.reference.repository, branch: input.reference.branch }
+            : {}),
+          ...(input.target === undefined ? {} : { target: input.target }),
+          ...(input.targetPath ? { targetPath: input.targetPath } : {}),
+          problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined),
+          source: input.source,
+        }
+        const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}`
+        return {
+          type: "text",
+          synthetic: true,
+          text: [
+            `Referenced configured reference ${label}.`,
+            ...(metadata.kind === "local" ? ["Kind: local directory"] : []),
+            ...(metadata.kind === "git" ? ["Kind: git repository"] : []),
+            ...(metadata.repository ? [`Repository: ${metadata.repository}`] : []),
+            ...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []),
+            ...(metadata.path ? [`Reference root: ${metadata.path}`] : []),
+            ...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []),
+            ...(metadata.problem
+              ? [`Problem: ${metadata.problem}`]
+              : [
+                  "For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.",
+                ]),
+          ].join("\n"),
+          metadata: { reference: metadata },
+        }
+      }
       yield* Effect.forEach(
         files,
         Effect.fnUntraced(function* (match) {
           const name = match[1]
+          if (!name) return
           if (seen.has(name)) return
           seen.add(name)
+
+          const slash = name.indexOf("/")
+          const alias = slash === -1 ? name : name.slice(0, slash)
+          const reference = yield* references.get(alias)
+          if (reference) {
+            const source = mentionSource(match)
+            if (reference.kind === "invalid") {
+              parts.push(
+                referenceTextPart({ reference, source, target: slash === -1 ? undefined : name.slice(slash + 1) }),
+              )
+              return
+            }
+
+            yield* references.ensure(reference.path)
+            if (slash === -1) {
+              parts.push(referenceTextPart({ reference, source }))
+              return
+            }
+
+            const target = name.slice(slash + 1)
+            const targetPath = path.resolve(reference.path, target)
+            if (!AppFileSystem.contains(reference.path, targetPath)) {
+              parts.push(
+                referenceTextPart({
+                  reference,
+                  source,
+                  target,
+                  targetPath,
+                  problem: `Path escapes configured reference @${alias}: ${target}`,
+                }),
+              )
+              return
+            }
+
+            const info = yield* fsys.stat(targetPath).pipe(Effect.option)
+            if (Option.isNone(info)) {
+              parts.push(
+                referenceTextPart({
+                  reference,
+                  source,
+                  target,
+                  targetPath,
+                  problem: `Path does not exist inside configured reference @${alias}: ${target}`,
+                }),
+              )
+              return
+            }
+
+            parts.push({
+              type: "file",
+              url: pathToFileURL(targetPath).href,
+              filename: name,
+              mime: info.value.type === "Directory" ? "application/x-directory" : "text/plain",
+            })
+            return
+          }
+
           const filepath = name.startsWith("~/")
             ? path.join(os.homedir(), name.slice(2))
             : path.resolve(ctx.worktree, name)
@@ -1326,6 +1471,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the
           if (part.type === "text") {
             if (part.synthetic) result.synthetic.push(part.text)
             else result.text.push(part.text)
+            const reference = referencePromptMetadata(part.metadata?.reference)
+            if (reference) {
+              result.references.push(
+                new ReferenceAttachment({
+                  name: reference.name,
+                  kind: reference.kind,
+                  uri: reference.path ? pathToFileURL(reference.path).href : undefined,
+                  repository: reference.repository,
+                  branch: reference.branch,
+                  target: reference.target,
+                  targetUri: reference.targetPath ? pathToFileURL(reference.targetPath).href : undefined,
+                  problem: reference.problem,
+                  source: new Source({
+                    start: reference.source.start,
+                    end: reference.source.end,
+                    text: reference.source.value,
+                  }),
+                }),
+              )
+            }
           }
           if (part.type === "file") {
             result.files.push(
@@ -1363,6 +1528,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
           text: [] as string[],
           files: [] as FileAttachment[],
           agents: [] as AgentAttachment[],
+          references: [] as ReferenceAttachment[],
           synthetic: [] as string[],
         },
       )
@@ -1375,6 +1541,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
             text: nextPrompt.text.join("\n"),
             files: nextPrompt.files,
             agents: nextPrompt.agents,
+            references: nextPrompt.references,
           },
         })
       }
@@ -1817,6 +1984,7 @@ export const defaultLayer = Layer.suspend(() =>
         Agent.defaultLayer,
         SystemPrompt.defaultLayer,
         LLM.defaultLayer,
+        Reference.defaultLayer,
         Bus.layer,
         CrossSpawnSpawner.defaultLayer,
         SyncEvent.defaultLayer,

+ 1 - 0
packages/opencode/src/v2/session-message-updater.ts

@@ -123,6 +123,7 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
           text: event.data.prompt.text,
           files: event.data.prompt.files,
           agents: event.data.prompt.agents,
+          references: event.data.prompt.references,
           time: { created: event.data.timestamp },
         }),
       )

+ 1 - 0
packages/opencode/src/v2/session-message.ts

@@ -34,6 +34,7 @@ export class User extends Schema.Class<User>("Session.Message.User")({
   text: Prompt.fields.text,
   files: Prompt.fields.files,
   agents: Prompt.fields.agents,
+  references: Prompt.fields.references,
   type: Schema.Literal("user"),
   time: Schema.Struct({
     created: V2Schema.DateTimeUtcFromMillis,

+ 13 - 0
packages/opencode/src/v2/session-prompt.ts

@@ -29,8 +29,21 @@ export class AgentAttachment extends Schema.Class<AgentAttachment>("Prompt.Agent
   source: Source.pipe(Schema.optional),
 }) {}
 
+export class ReferenceAttachment extends Schema.Class<ReferenceAttachment>("Prompt.ReferenceAttachment")({
+  name: Schema.String,
+  kind: Schema.Literals(["local", "git", "invalid"]),
+  uri: Schema.String.pipe(Schema.optional),
+  repository: Schema.String.pipe(Schema.optional),
+  branch: Schema.String.pipe(Schema.optional),
+  target: Schema.String.pipe(Schema.optional),
+  targetUri: Schema.String.pipe(Schema.optional),
+  problem: Schema.String.pipe(Schema.optional),
+  source: Source.pipe(Schema.optional),
+}) {}
+
 export class Prompt extends Schema.Class<Prompt>("Prompt")({
   text: Schema.String,
   files: Schema.Array(FileAttachment).pipe(Schema.optional),
   agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
+  references: Schema.Array(ReferenceAttachment).pipe(Schema.optional),
 }) {}

+ 8 - 44
packages/opencode/test/agent/agent.test.ts

@@ -141,16 +141,12 @@ test("scout agent allows repo cloning and repo cache reads", async () => {
   })
 })
 
-test("reference config creates scout-backed subagents", async () => {
+test("reference config does not create subagents", async () => {
   await withExperimentalScout(true, async () => {
     await using tmp = await tmpdir({
       config: {
         reference: {
           effect: "github.com/effect/effect-smol",
-          effectDev: {
-            repository: "https://github.com/effect/effect-smol",
-            branch: "dev",
-          },
           effectFull: {
             repository: "Effect-TS/effect",
             branch: "main",
@@ -165,45 +161,13 @@ test("reference config creates scout-backed subagents", async () => {
     await WithInstance.provide({
       directory: tmp.path,
       fn: async () => {
-        const effect = await load(tmp.path, (svc) => svc.get("effect"))
-        const effectDev = await load(tmp.path, (svc) => svc.get("effectDev"))
-        const effectFull = await load(tmp.path, (svc) => svc.get("effectFull"))
-        const local = await load(tmp.path, (svc) => svc.get("localdocs"))
-        const localFull = await load(tmp.path, (svc) => svc.get("localdocsFull"))
-
-        expect(effect).toBeDefined()
-        expect(effect?.mode).toBe("subagent")
-        expect(effect?.prompt).toContain("Repository: github.com/effect/effect-smol")
-        expect(effect?.prompt).toContain(
-          `Cached directory: ${path.join(Global.Path.repos, "github.com", "effect", "effect-smol")}`,
-        )
-        expect(effect?.prompt).toContain("Do not call repo_clone")
-        expect(evalPerm(effect, "repo_clone")).toBe("deny")
-
-        expect(effectDev).toBeDefined()
-        expect(effectDev?.prompt).toContain("Problem: Reference conflicts with @effect")
-        expect(effectDev?.prompt).not.toContain("Cached directory:")
-
-        expect(effectFull).toBeDefined()
-        expect(effectFull?.mode).toBe("subagent")
-        expect(effectFull?.prompt).toContain("Repository: Effect-TS/effect")
-        expect(effectFull?.prompt).toContain("Branch/ref: main")
-        expect(evalPerm(effectFull, "repo_clone")).toBe("deny")
-
-        expect(local).toBeDefined()
-        expect(local?.mode).toBe("subagent")
-        expect(local?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../docs")}`)
-        expect(
-          Permission.evaluate(
-            "external_directory",
-            path.join(path.resolve(tmp.path, "../docs"), "README.md"),
-            local!.permission,
-          ).action,
-        ).toBe("allow")
-
-        expect(localFull).toBeDefined()
-        expect(localFull?.mode).toBe("subagent")
-        expect(localFull?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../local-docs")}`)
+        const agents = await load(tmp.path, (svc) => svc.list())
+        const names = agents.map((agent) => agent.name)
+        expect(names).toContain("scout")
+        expect(names).not.toContain("effect")
+        expect(names).not.toContain("effectFull")
+        expect(names).not.toContain("localdocs")
+        expect(names).not.toContain("localdocsFull")
       },
     })
   })

+ 97 - 0
packages/opencode/test/session/prompt.test.ts

@@ -2,6 +2,7 @@ import { NodeFileSystem } from "@effect/platform-node"
 import { FetchHttpClient } from "effect/unstable/http"
 import { expect } from "bun:test"
 import { Cause, Effect, Exit, Fiber, Layer } from "effect"
+import fs from "fs/promises"
 import path from "path"
 import { fileURLToPath } from "url"
 import { NamedError } from "@opencode-ai/core/util/error"
@@ -203,6 +204,7 @@ function makeHttp() {
     SessionPrompt.layer.pipe(
       Layer.provide(SessionRevert.defaultLayer),
       Layer.provide(Image.defaultLayer),
+      Layer.provide(Reference.defaultLayer),
       Layer.provide(summary),
       Layer.provideMerge(run),
       Layer.provideMerge(compact),
@@ -1791,6 +1793,101 @@ it.live("keeps stored part order stable when file resolution is async", () =>
   ),
 )
 
+it.live("resolves configured reference mentions before workspace paths and agents", () =>
+  provideTmpdirInstance(
+    (dir) =>
+      Effect.gen(function* () {
+        const docs = path.join(dir, "external-docs")
+        yield* Effect.promise(() => fs.mkdir(path.join(docs, "guide"), { recursive: true }))
+        yield* Effect.promise(() => fs.mkdir(path.join(dir, "docs"), { recursive: true }))
+        yield* Effect.promise(() => Bun.write(path.join(docs, "README.md"), "reference readme"))
+        yield* Effect.promise(() => Bun.write(path.join(docs, "guide", "intro.md"), "reference intro"))
+        yield* Effect.promise(() => Bun.write(path.join(dir, "docs", "README.md"), "workspace readme"))
+
+        const prompt = yield* SessionPrompt.Service
+        const parts = yield* prompt.resolvePromptParts(
+          "Use @docs and @docs/README.md and @docs/guide and @docs/missing.md and @docs/README.md and @build",
+        )
+        const references = parts.filter(
+          (part): part is MessageV2.TextPartInput =>
+            part.type === "text" && part.synthetic === true && part.text.startsWith("Referenced configured reference "),
+        )
+        const files = parts.filter((part): part is MessageV2.FilePartInput => part.type === "file")
+        const agents = parts.filter((part): part is MessageV2.AgentPartInput => part.type === "agent")
+        const bare = references.find((part) => part.text.includes("@docs."))
+        const missing = references.find((part) => part.text.includes("@docs/missing.md"))
+        const guide = files.find((part) => part.filename === "docs/guide")
+
+        expect(references.length).toBe(2)
+        expect(bare?.metadata?.reference).toMatchObject({
+          name: "docs",
+          kind: "local",
+          path: docs,
+        })
+        expect(missing?.text).toContain("Path does not exist inside configured reference @docs")
+        expect(missing?.metadata?.reference).toMatchObject({
+          target: "missing.md",
+          targetPath: path.join(docs, "missing.md"),
+        })
+
+        expect(files.length).toBe(2)
+        expect(files.map((file) => fileURLToPath(file.url)).sort()).toEqual(
+          [path.join(docs, "README.md"), path.join(docs, "guide")].sort(),
+        )
+        expect(guide?.mime).toBe("application/x-directory")
+        expect(agents.map((agent) => agent.name)).toEqual(["build"])
+      }),
+    {
+      git: true,
+      config: {
+        ...cfg,
+        reference: {
+          docs: "./external-docs",
+        },
+      },
+    },
+  ),
+)
+
+it.live("injects metadata for bare configured reference mentions", () =>
+  provideTmpdirInstance(
+    (dir) =>
+      Effect.gen(function* () {
+        const docs = path.join(dir, "external-docs")
+        yield* Effect.promise(() => fs.mkdir(docs, { recursive: true }))
+
+        const prompt = yield* SessionPrompt.Service
+        const sessions = yield* Session.Service
+        const session = yield* sessions.create({})
+        const message = yield* prompt.prompt({
+          sessionID: session.id,
+          noReply: true,
+          parts: yield* prompt.resolvePromptParts("Use @docs for context"),
+        })
+
+        const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id })
+        const synthetic = stored.parts
+          .filter((part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true)
+        const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs."))
+
+        expect(reference?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: docs })
+        expect(synthetic.some((part) => part.text.includes(`Reference root: ${docs}`))).toBe(true)
+        expect(synthetic.some((part) => part.text.includes("subagent scout"))).toBe(true)
+
+        yield* sessions.remove(session.id)
+      }),
+    {
+      git: true,
+      config: {
+        ...cfg,
+        reference: {
+          docs: "./external-docs",
+        },
+      },
+    },
+  ),
+)
+
 // Special characters in filenames
 
 it.live("handles filenames with # character", () =>

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

@@ -154,6 +154,7 @@ function makeHttp() {
     SessionPrompt.layer.pipe(
       Layer.provide(SessionRevert.defaultLayer),
       Layer.provide(Image.defaultLayer),
+      Layer.provide(Reference.defaultLayer),
       Layer.provide(SessionSummary.defaultLayer),
       Layer.provideMerge(run),
       Layer.provideMerge(compact),

+ 14 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -771,6 +771,7 @@ export type Prompt = {
   text: string
   files?: Array<PromptFileAttachment>
   agents?: Array<PromptAgentAttachment>
+  references?: Array<PromptReferenceAttachment>
 }
 
 export type GlobalEvent = {
@@ -2718,6 +2719,18 @@ export type PromptAgentAttachment = {
   source?: PromptSource
 }
 
+export type PromptReferenceAttachment = {
+  name: string
+  kind: "local" | "git" | "invalid"
+  uri?: string
+  repository?: string
+  branch?: string
+  target?: string
+  targetUri?: string
+  problem?: string
+  source?: PromptSource
+}
+
 export type EventSessionNextPrompted = {
   id: string
   type: "session.next.prompted"
@@ -3121,6 +3134,7 @@ export type SessionMessageUser = {
   text: string
   files?: Array<PromptFileAttachment>
   agents?: Array<PromptAgentAttachment>
+  references?: Array<PromptReferenceAttachment>
   type: "user"
 }