fix: route reference mentions through scout

This commit is contained in:
Shoubhit Dash
2026-05-09 14:24:30 +05:30
parent 53a50d6f3c
commit fc1cefeb8e
5 changed files with 142 additions and 28 deletions

View File

@@ -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<Config.Info["reference"]>[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(

View File

@@ -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<typeof Info>
export type Entry = Schema.Schema.Type<typeof Entry>
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")
}

View File

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

View File

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

View File

@@ -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<A, E, R>(fx: () => Effect.Effect<A, E, R>) {
)
}
function withScout<A, E, R>(fx: Effect.Effect<A, E, R>) {
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 }) {