From fc1cefeb8ef50da17cf44b4ba476c20f0bb0f616 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 9 May 2026 14:24:30 +0530 Subject: [PATCH] fix: route reference mentions through scout --- packages/opencode/src/agent/agent.ts | 43 ++++++------- packages/opencode/src/config/reference.ts | 43 +++++++++++++ packages/opencode/src/session/prompt.ts | 19 +++++- packages/opencode/src/tool/registry.ts | 4 +- packages/opencode/test/session/prompt.test.ts | 61 +++++++++++++++++++ 5 files changed, 142 insertions(+), 28 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 8584682412..d896fbd2be 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,4 +1,5 @@ import { Config } from "@/config/config" +import { ConfigReference } from "@/config/reference" import z from "zod" import { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "../provider/schema" @@ -27,9 +28,6 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer" import { zod } from "@/util/effect-zod" import { withStatics, type DeepMutable } from "@/util/schema" -type ReferenceEntry = NonNullable[string] -type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string } - export const Info = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), @@ -303,25 +301,7 @@ export const layer = Layer.effect( item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) } - function referencePath(value: string) { - if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2)) - return path.isAbsolute(value) - ? value - : path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value) - } - - function resolveReference(reference: ReferenceEntry): ResolvedReference { - if (typeof reference === "string") { - if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) { - return { kind: "local", path: referencePath(reference) } - } - return { kind: "git", repository: reference } - } - if ("path" in reference) return { kind: "local", path: referencePath(reference.path) } - return { kind: "git", repository: reference.repository, branch: reference.branch } - } - - function referencePrompt(name: string, reference: ResolvedReference) { + function referencePrompt(name: string, reference: ConfigReference.Resolved) { if (reference.kind === "local") { return [ PROMPT_SCOUT, @@ -343,14 +323,27 @@ export const layer = Layer.effect( if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) { for (const [name, reference] of Object.entries(cfg.reference ?? {})) { if (agents[name]) continue - const resolved = resolveReference(reference) + const resolved = ConfigReference.resolve(reference, ctx) const localPath = resolved.kind === "local" ? resolved.path : undefined + agents.scout.permission = Permission.merge( + agents.scout.permission, + Permission.fromConfig( + localPath + ? { + external_directory: { + [localPath]: "allow", + [path.join(localPath, "*")]: "allow", + }, + } + : {}, + ), + ) agents[name] = { name, description: resolved.kind === "local" - ? `Scout reference for local directory ${resolved.path}` - : `Scout reference for repository ${resolved.repository}`, + ? `Reference alias for Scout using local directory ${resolved.path}` + : `Reference alias for Scout using repository ${resolved.repository}`, permission: Permission.merge( agents.scout.permission, Permission.fromConfig( diff --git a/packages/opencode/src/config/reference.ts b/packages/opencode/src/config/reference.ts index eea3d998c1..51f66a8a8d 100644 --- a/packages/opencode/src/config/reference.ts +++ b/packages/opencode/src/config/reference.ts @@ -1,8 +1,10 @@ export * as ConfigReference from "./reference" import { Schema } from "effect" +import { Global } from "@opencode-ai/core/global" import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" +import path from "path" const Git = Schema.Struct({ repository: Schema.String.annotate({ @@ -25,3 +27,44 @@ export const Info = Schema.Record(Schema.String, Entry) .annotate({ identifier: "ReferenceConfig" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type Info = Schema.Schema.Type + +export type Entry = Schema.Schema.Type +export type Resolved = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string } + +type Context = { + directory: string + worktree: string +} + +function referencePath(value: string, ctx: Context) { + if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2)) + return path.isAbsolute(value) ? value : path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value) +} + +export function resolve(reference: Entry, ctx: Context): Resolved { + if (typeof reference === "string") { + if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) { + return { kind: "local", path: referencePath(reference, ctx) } + } + return { kind: "git", repository: reference } + } + if ("path" in reference) return { kind: "local", path: referencePath(reference.path, ctx) } + return { kind: "git", repository: reference.repository, branch: reference.branch } +} + +export function prompt(name: string, reference: Resolved) { + if (reference.kind === "local") { + return [ + `@${name} is a configured Scout reference, not a separate subagent or skill.`, + `Local directory: ${reference.path}`, + `In the task prompt, tell Scout to inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files in the reference.`, + ].join("\n") + } + + return [ + `@${name} is a configured Scout reference, not a separate subagent or skill.`, + `Repository: ${reference.repository}`, + ...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []), + "In the task prompt, tell Scout to clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files in the reference.", + ].join("\n") +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fef8c43836..2e62e71a74 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -32,6 +32,7 @@ import { Command } from "../command" import { pathToFileURL, fileURLToPath } from "url" import { Config } from "@/config/config" import { ConfigMarkdown } from "@/config/markdown" +import { ConfigReference } from "@/config/reference" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/core/util/error" import { SessionProcessor } from "./processor" @@ -1239,8 +1240,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the } if (part.type === "agent") { - const perm = Permission.evaluate("task", part.name, ag.permission) + const cfg = yield* config.get() + const reference = Flag.OPENCODE_EXPERIMENTAL_SCOUT + ? cfg.reference?.[part.name] + ? ConfigReference.resolve(cfg.reference[part.name], yield* InstanceState.context) + : undefined + : undefined + const target = reference ? "scout" : part.name + const perm = Permission.evaluate("task", target, ag.permission) const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : "" + const referencePrompt = reference + ? ConfigReference.prompt(part.name, reference) + + "\nCall the task tool with subagent: scout. Do not call a subagent or skill named " + + part.name + + "." + : undefined return [ { ...part, messageID: info.id, sessionID: input.sessionID }, { @@ -1249,8 +1263,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the type: "text", synthetic: true, text: + (referencePrompt ? referencePrompt + "\n" : "") + " Use the above message and context to generate a prompt and call the task tool with subagent: " + - part.name + + target + hint, }, ] diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index c8a91c1de1..3047ed813d 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -286,7 +286,9 @@ export const layer: Layer.Layer< }) const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) { - const items = (yield* agents.list()).filter((item) => item.mode !== "primary") + const items = (yield* agents.list()).filter( + (item) => item.mode !== "primary" && item.options.reference === undefined, + ) const filtered = items.filter( (item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny", ) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 3b0009d2b3..739d26bfcd 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -24,6 +24,7 @@ import { SessionMessageTable } from "../../src/session/session.sql" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" import { SessionCompaction } from "../../src/session/compaction" import { SessionSummary } from "../../src/session/summary" import { Instruction } from "../../src/session/instruction" @@ -91,6 +92,21 @@ function withSh(fx: () => Effect.Effect) { ) } +function withScout(fx: Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const prev = Flag.OPENCODE_EXPERIMENTAL_SCOUT + Flag.OPENCODE_EXPERIMENTAL_SCOUT = true + return prev + }), + () => fx, + (prev) => + Effect.sync(() => { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = prev + }), + ) +} + function toolPart(parts: MessageV2.Part[]) { return parts.find((part): part is MessageV2.ToolPart => part.type === "tool") } @@ -417,6 +433,51 @@ it.live("prompt emits v2 prompted and synthetic events", () => ), ) +it.live("reference mentions route through scout", () => + provideTmpdirServer( + () => + withScout( + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + const result = yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [ + { type: "text", text: "@effect audit this code" }, + { + type: "agent", + name: "effect", + source: { value: "@effect", start: 0, end: 7 }, + }, + ], + }) + + const synthetic = result.parts.findLast((part) => part.type === "text" && part.synthetic) + expect(synthetic?.type).toBe("text") + if (synthetic?.type !== "text") return + expect(synthetic.text).toContain("@effect is a configured Scout reference") + expect(synthetic.text).toContain("Repository: Effect-TS/effect") + expect(synthetic.text).toContain("subagent: scout") + expect(synthetic.text).toContain("Do not call a subagent or skill named effect") + expect(synthetic.text).not.toContain("subagent: effect") + }), + ), + { + git: true, + config: (url) => ({ + ...providerCfg(url), + reference: { + effect: "Effect-TS/effect", + }, + }), + }, + ), +) + it.live("static loop returns assistant text through local provider", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) {