Skip to content

Commit 7e997cf

Browse files
authored
refactor(scout): resolve configured reference mentions (#26701)
1 parent 5d6f2a1 commit 7e997cf

9 files changed

Lines changed: 304 additions & 116 deletions

File tree

packages/opencode/src/agent/agent.ts

Lines changed: 0 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import * as Option from "effect/Option"
2626
import * as OtelTracer from "@effect/opentelemetry/Tracer"
2727
import { zod } from "@opencode-ai/core/effect-zod"
2828
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
29-
import { Reference } from "@/reference/reference"
3029

3130
export const Info = Schema.Struct({
3231
name: Schema.String,
@@ -301,76 +300,6 @@ export const layer = Layer.effect(
301300
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
302301
}
303302

304-
function referencePrompt(reference: Reference.Resolved) {
305-
if (reference.kind === "local") {
306-
return [
307-
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
308-
`Local directory: ${reference.path}`,
309-
`Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
310-
`Return exact absolute file paths for findings whenever possible.`,
311-
].join("\n\n")
312-
}
313-
314-
if (reference.kind === "invalid") {
315-
return [
316-
`You are configured reference @${reference.name}, but this reference is not usable yet.`,
317-
`Configured repository: ${reference.repository}`,
318-
`Problem: ${reference.message}`,
319-
`Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`,
320-
].join("\n\n")
321-
}
322-
323-
return [
324-
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
325-
`Repository: ${reference.repository}`,
326-
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
327-
`Cached directory: ${reference.path}`,
328-
`OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`,
329-
`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.`,
330-
`Return exact absolute file paths for findings whenever possible.`,
331-
].join("\n\n")
332-
}
333-
334-
function referenceDescription(reference: Reference.Resolved) {
335-
if (reference.kind === "local") return `Scout reference for local directory ${reference.path}`
336-
if (reference.kind === "git") return `Scout reference for repository ${reference.repository}`
337-
return `Invalid Scout reference for repository ${reference.repository}`
338-
}
339-
340-
if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
341-
const resolvedReferences = Reference.resolveAll({
342-
references: cfg.reference ?? {},
343-
directory: ctx.directory,
344-
worktree: ctx.worktree,
345-
})
346-
for (const resolved of resolvedReferences) {
347-
if (agents[resolved.name]) continue
348-
const localPath = resolved.kind === "invalid" ? undefined : resolved.path
349-
agents[resolved.name] = {
350-
name: resolved.name,
351-
description: referenceDescription(resolved),
352-
permission: Permission.merge(
353-
agents.scout.permission,
354-
Permission.fromConfig({
355-
repo_clone: "deny",
356-
...(localPath
357-
? {
358-
external_directory: {
359-
[localPath]: "allow",
360-
[path.join(localPath, "*")]: "allow",
361-
},
362-
}
363-
: {}),
364-
}),
365-
),
366-
prompt: referencePrompt(resolved),
367-
options: { reference: cfg.reference?.[resolved.name], resolved },
368-
mode: "subagent",
369-
native: false,
370-
}
371-
}
372-
}
373-
374303
// Ensure Truncate.GLOB is allowed unless explicitly configured
375304
for (const name in agents) {
376305
const agent = agents[name]

packages/opencode/src/session/prompt.ts

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ import { EffectBridge } from "@/effect/bridge"
5656
import { SyncEvent } from "@/sync"
5757
import { SessionEvent } from "@/v2/session-event"
5858
import { Modelv2 } from "@/v2/model"
59-
import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt"
59+
import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@/v2/session-prompt"
60+
import { Reference } from "@/reference/reference"
6061
import * as DateTime from "effect/DateTime"
6162
import { eq } from "@/storage/db"
6263
import * as Database from "@/storage/db"
@@ -81,6 +82,45 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
8182
const log = Log.create({ service: "session.prompt" })
8283
const elog = EffectLogger.create({ service: "session.prompt" })
8384

85+
type ReferencePromptMetadata = {
86+
name: string
87+
kind: "local" | "git" | "invalid"
88+
path?: string
89+
repository?: string
90+
branch?: string
91+
target?: string
92+
targetPath?: string
93+
problem?: string
94+
source: { value: string; start: number; end: number }
95+
}
96+
97+
function stringField(record: Record<string, unknown>, key: string) {
98+
return typeof record[key] === "string" ? record[key] : undefined
99+
}
100+
101+
function referencePromptMetadata(input: unknown): ReferencePromptMetadata | undefined {
102+
if (!input || typeof input !== "object" || Array.isArray(input)) return
103+
const record = input as Record<string, unknown>
104+
const name = stringField(record, "name")
105+
const kind = stringField(record, "kind")
106+
if (!name || (kind !== "local" && kind !== "git" && kind !== "invalid")) return
107+
if (!record.source || typeof record.source !== "object" || Array.isArray(record.source)) return
108+
const source = record.source as Record<string, unknown>
109+
const value = stringField(source, "value")
110+
if (!value || typeof source.start !== "number" || typeof source.end !== "number") return
111+
return {
112+
name,
113+
kind,
114+
path: stringField(record, "path"),
115+
repository: stringField(record, "repository"),
116+
branch: stringField(record, "branch"),
117+
target: stringField(record, "target"),
118+
targetPath: stringField(record, "targetPath"),
119+
problem: stringField(record, "problem"),
120+
source: { value, start: source.start, end: source.end },
121+
}
122+
}
123+
84124
export interface Interface {
85125
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
86126
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
@@ -119,6 +159,7 @@ export const layer = Layer.effect(
119159
const summary = yield* SessionSummary.Service
120160
const sys = yield* SystemPrompt.Service
121161
const llm = yield* LLM.Service
162+
const references = yield* Reference.Service
122163
const sync = yield* SyncEvent.Service
123164
const runner = Effect.fn("SessionPrompt.runner")(function* () {
124165
return yield* EffectBridge.make()
@@ -141,12 +182,116 @@ export const layer = Layer.effect(
141182
const parts: Types.DeepMutable<PromptInput["parts"]> = [{ type: "text", text: template }]
142183
const files = ConfigMarkdown.files(template)
143184
const seen = new Set<string>()
185+
const mentionSource = (match: RegExpMatchArray) => {
186+
const start = match.index ?? 0
187+
return { value: match[0], start, end: start + match[0].length }
188+
}
189+
const referenceTextPart = (input: {
190+
reference: Reference.Resolved
191+
source: ReturnType<typeof mentionSource>
192+
target?: string
193+
targetPath?: string
194+
problem?: string
195+
}): MessageV2.TextPartInput => {
196+
const metadata: ReferencePromptMetadata = {
197+
name: input.reference.name,
198+
kind: input.reference.kind,
199+
...(input.reference.kind === "invalid"
200+
? { repository: input.reference.repository }
201+
: { path: input.reference.path }),
202+
...(input.reference.kind === "git"
203+
? { repository: input.reference.repository, branch: input.reference.branch }
204+
: {}),
205+
...(input.target === undefined ? {} : { target: input.target }),
206+
...(input.targetPath ? { targetPath: input.targetPath } : {}),
207+
problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined),
208+
source: input.source,
209+
}
210+
const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}`
211+
return {
212+
type: "text",
213+
synthetic: true,
214+
text: [
215+
`Referenced configured reference ${label}.`,
216+
...(metadata.kind === "local" ? ["Kind: local directory"] : []),
217+
...(metadata.kind === "git" ? ["Kind: git repository"] : []),
218+
...(metadata.repository ? [`Repository: ${metadata.repository}`] : []),
219+
...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []),
220+
...(metadata.path ? [`Reference root: ${metadata.path}`] : []),
221+
...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []),
222+
...(metadata.problem
223+
? [`Problem: ${metadata.problem}`]
224+
: [
225+
"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.",
226+
]),
227+
].join("\n"),
228+
metadata: { reference: metadata },
229+
}
230+
}
144231
yield* Effect.forEach(
145232
files,
146233
Effect.fnUntraced(function* (match) {
147234
const name = match[1]
235+
if (!name) return
148236
if (seen.has(name)) return
149237
seen.add(name)
238+
239+
const slash = name.indexOf("/")
240+
const alias = slash === -1 ? name : name.slice(0, slash)
241+
const reference = yield* references.get(alias)
242+
if (reference) {
243+
const source = mentionSource(match)
244+
if (reference.kind === "invalid") {
245+
parts.push(
246+
referenceTextPart({ reference, source, target: slash === -1 ? undefined : name.slice(slash + 1) }),
247+
)
248+
return
249+
}
250+
251+
yield* references.ensure(reference.path)
252+
if (slash === -1) {
253+
parts.push(referenceTextPart({ reference, source }))
254+
return
255+
}
256+
257+
const target = name.slice(slash + 1)
258+
const targetPath = path.resolve(reference.path, target)
259+
if (!AppFileSystem.contains(reference.path, targetPath)) {
260+
parts.push(
261+
referenceTextPart({
262+
reference,
263+
source,
264+
target,
265+
targetPath,
266+
problem: `Path escapes configured reference @${alias}: ${target}`,
267+
}),
268+
)
269+
return
270+
}
271+
272+
const info = yield* fsys.stat(targetPath).pipe(Effect.option)
273+
if (Option.isNone(info)) {
274+
parts.push(
275+
referenceTextPart({
276+
reference,
277+
source,
278+
target,
279+
targetPath,
280+
problem: `Path does not exist inside configured reference @${alias}: ${target}`,
281+
}),
282+
)
283+
return
284+
}
285+
286+
parts.push({
287+
type: "file",
288+
url: pathToFileURL(targetPath).href,
289+
filename: name,
290+
mime: info.value.type === "Directory" ? "application/x-directory" : "text/plain",
291+
})
292+
return
293+
}
294+
150295
const filepath = name.startsWith("~/")
151296
? path.join(os.homedir(), name.slice(2))
152297
: 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
13261471
if (part.type === "text") {
13271472
if (part.synthetic) result.synthetic.push(part.text)
13281473
else result.text.push(part.text)
1474+
const reference = referencePromptMetadata(part.metadata?.reference)
1475+
if (reference) {
1476+
result.references.push(
1477+
new ReferenceAttachment({
1478+
name: reference.name,
1479+
kind: reference.kind,
1480+
uri: reference.path ? pathToFileURL(reference.path).href : undefined,
1481+
repository: reference.repository,
1482+
branch: reference.branch,
1483+
target: reference.target,
1484+
targetUri: reference.targetPath ? pathToFileURL(reference.targetPath).href : undefined,
1485+
problem: reference.problem,
1486+
source: new Source({
1487+
start: reference.source.start,
1488+
end: reference.source.end,
1489+
text: reference.source.value,
1490+
}),
1491+
}),
1492+
)
1493+
}
13291494
}
13301495
if (part.type === "file") {
13311496
result.files.push(
@@ -1363,6 +1528,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
13631528
text: [] as string[],
13641529
files: [] as FileAttachment[],
13651530
agents: [] as AgentAttachment[],
1531+
references: [] as ReferenceAttachment[],
13661532
synthetic: [] as string[],
13671533
},
13681534
)
@@ -1375,6 +1541,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
13751541
text: nextPrompt.text.join("\n"),
13761542
files: nextPrompt.files,
13771543
agents: nextPrompt.agents,
1544+
references: nextPrompt.references,
13781545
},
13791546
})
13801547
}
@@ -1817,6 +1984,7 @@ export const defaultLayer = Layer.suspend(() =>
18171984
Agent.defaultLayer,
18181985
SystemPrompt.defaultLayer,
18191986
LLM.defaultLayer,
1987+
Reference.defaultLayer,
18201988
Bus.layer,
18211989
CrossSpawnSpawner.defaultLayer,
18221990
SyncEvent.defaultLayer,

packages/opencode/src/v2/session-message-updater.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
123123
text: event.data.prompt.text,
124124
files: event.data.prompt.files,
125125
agents: event.data.prompt.agents,
126+
references: event.data.prompt.references,
126127
time: { created: event.data.timestamp },
127128
}),
128129
)

packages/opencode/src/v2/session-message.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export class User extends Schema.Class<User>("Session.Message.User")({
3434
text: Prompt.fields.text,
3535
files: Prompt.fields.files,
3636
agents: Prompt.fields.agents,
37+
references: Prompt.fields.references,
3738
type: Schema.Literal("user"),
3839
time: Schema.Struct({
3940
created: V2Schema.DateTimeUtcFromMillis,

packages/opencode/src/v2/session-prompt.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,21 @@ export class AgentAttachment extends Schema.Class<AgentAttachment>("Prompt.Agent
2929
source: Source.pipe(Schema.optional),
3030
}) {}
3131

32+
export class ReferenceAttachment extends Schema.Class<ReferenceAttachment>("Prompt.ReferenceAttachment")({
33+
name: Schema.String,
34+
kind: Schema.Literals(["local", "git", "invalid"]),
35+
uri: Schema.String.pipe(Schema.optional),
36+
repository: Schema.String.pipe(Schema.optional),
37+
branch: Schema.String.pipe(Schema.optional),
38+
target: Schema.String.pipe(Schema.optional),
39+
targetUri: Schema.String.pipe(Schema.optional),
40+
problem: Schema.String.pipe(Schema.optional),
41+
source: Source.pipe(Schema.optional),
42+
}) {}
43+
3244
export class Prompt extends Schema.Class<Prompt>("Prompt")({
3345
text: Schema.String,
3446
files: Schema.Array(FileAttachment).pipe(Schema.optional),
3547
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
48+
references: Schema.Array(ReferenceAttachment).pipe(Schema.optional),
3649
}) {}

0 commit comments

Comments
 (0)