From 7e997cfba418f481e08b06214907ebf938c5f6dd Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 11 May 2026 12:57:26 +0530 Subject: [PATCH] refactor(scout): resolve configured reference mentions (#26701) --- packages/opencode/src/agent/agent.ts | 71 -------- packages/opencode/src/session/prompt.ts | 170 +++++++++++++++++- .../src/v2/session-message-updater.ts | 1 + packages/opencode/src/v2/session-message.ts | 1 + packages/opencode/src/v2/session-prompt.ts | 13 ++ packages/opencode/test/agent/agent.test.ts | 52 +----- packages/opencode/test/session/prompt.test.ts | 97 ++++++++++ .../test/session/snapshot-tool-race.test.ts | 1 + packages/sdk/js/src/v2/gen/types.gen.ts | 14 ++ 9 files changed, 304 insertions(+), 116 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 5917240cdb..777f6e6d17 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/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] diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7f4f608556..3b919e2f0a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/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, 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 + 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 + 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 readonly prompt: (input: PromptInput) => Effect.Effect @@ -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 = [{ type: "text", text: template }] const files = ConfigMarkdown.files(template) const seen = new Set() + 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 + 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, diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts index 80ecb1011e..bbdf59c555 100644 --- a/packages/opencode/src/v2/session-message-updater.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -123,6 +123,7 @@ export function update(adapter: Adapter, 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 }, }), ) diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts index 024e28c450..62fc75fc83 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/opencode/src/v2/session-message.ts @@ -34,6 +34,7 @@ export class User extends Schema.Class("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, diff --git a/packages/opencode/src/v2/session-prompt.ts b/packages/opencode/src/v2/session-prompt.ts index 86d8e52eb7..14167fc288 100644 --- a/packages/opencode/src/v2/session-prompt.ts +++ b/packages/opencode/src/v2/session-prompt.ts @@ -29,8 +29,21 @@ export class AgentAttachment extends Schema.Class("Prompt.Agent source: Source.pipe(Schema.optional), }) {} +export class ReferenceAttachment extends Schema.Class("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")({ text: Schema.String, files: Schema.Array(FileAttachment).pipe(Schema.optional), agents: Schema.Array(AgentAttachment).pipe(Schema.optional), + references: Schema.Array(ReferenceAttachment).pipe(Schema.optional), }) {} diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index cb6f60503f..df68fdfdc6 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/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") }, }) }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 42c9a81cd2..cb771aee35 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/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", () => diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 5c47df4c0d..8640612e98 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/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), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5bba8efc0b..da80645ad7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -771,6 +771,7 @@ export type Prompt = { text: string files?: Array agents?: Array + references?: Array } 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 agents?: Array + references?: Array type: "user" }