mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-13 15:44:56 +00:00
fix: route reference mentions through scout
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user