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

This commit is contained in:
Shoubhit Dash
2026-05-11 12:57:26 +05:30
committed by GitHub
parent 5d6f2a1524
commit 7e997cfba4
9 changed files with 304 additions and 116 deletions

View File

@@ -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]

View File

@@ -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,

View File

@@ -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 },
}),
)

View File

@@ -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,

View File

@@ -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),
}) {}

View File

@@ -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")
},
})
})

View File

@@ -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", () =>

View File

@@ -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),

View File

@@ -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"
}