Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 0 additions & 71 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]
Expand Down
170 changes: 169 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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>
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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[],
},
)
Expand All @@ -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,
},
})
}
Expand Down Expand Up @@ -1817,6 +1984,7 @@ export const defaultLayer = Layer.suspend(() =>
Agent.defaultLayer,
SystemPrompt.defaultLayer,
LLM.defaultLayer,
Reference.defaultLayer,
Bus.layer,
CrossSpawnSpawner.defaultLayer,
SyncEvent.defaultLayer,
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/v2/session-message-updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
}),
)
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/v2/session-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions packages/opencode/src/v2/session-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}) {}
Loading
Loading