Skip to content

Commit 6b062ed

Browse files
Apply PR #24149: feat(core): add scout agent for repo research
2 parents 8b4b824 + 5015536 commit 6b062ed

42 files changed

Lines changed: 1520 additions & 51 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/core/src/global.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const paths = {
2020
data,
2121
bin: path.join(cache, "bin"),
2222
log: path.join(data, "log"),
23+
repos: path.join(data, "repos"),
2324
cache,
2425
config,
2526
state,
@@ -37,6 +38,7 @@ await Promise.all([
3738
fs.mkdir(Path.tmp, { recursive: true }),
3839
fs.mkdir(Path.log, { recursive: true }),
3940
fs.mkdir(Path.bin, { recursive: true }),
41+
fs.mkdir(Path.repos, { recursive: true }),
4042
])
4143

4244
export class Service extends Context.Service<Service, Interface>()("@opencode/Global") {}
@@ -50,6 +52,7 @@ export interface Interface {
5052
readonly tmp: string
5153
readonly bin: string
5254
readonly log: string
55+
readonly repos: string
5356
}
5457

5558
export function make(input: Partial<Interface> = {}): Interface {
@@ -62,6 +65,7 @@ export function make(input: Partial<Interface> = {}): Interface {
6265
tmp: Path.tmp,
6366
bin: Path.bin,
6467
log: Path.log,
68+
repos: Path.repos,
6569
...input,
6670
}
6771
}

packages/opencode/src/acp/agent.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1565,6 +1565,8 @@ function toToolKind(toolName: string): ToolKind {
15651565

15661566
case "grep":
15671567
case "glob":
1568+
case "repo_clone":
1569+
case "repo_overview":
15681570
case "context7_resolve_library_id":
15691571
case "context7_get_library_docs":
15701572
return "search"
@@ -1589,6 +1591,11 @@ function toLocations(toolName: string, input: Record<string, any>): { path: stri
15891591
case "grep":
15901592
return input["path"] ? [{ path: input["path"] }] : []
15911593
case ShellToolID.id:
1594+
case "repo_clone":
1595+
return input["path"] ? [{ path: input["path"] }] : []
1596+
case "repo_overview":
1597+
return input["path"] ? [{ path: input["path"] }] : []
1598+
case "bash":
15921599
return []
15931600
default:
15941601
return []

packages/opencode/src/agent/agent.ts

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ProviderTransform } from "@/provider/transform"
1010
import PROMPT_GENERATE from "./generate.txt"
1111
import PROMPT_COMPACTION from "./prompt/compaction.txt"
1212
import PROMPT_EXPLORE from "./prompt/explore.txt"
13+
import PROMPT_SCOUT from "./prompt/scout.txt"
1314
import PROMPT_SUMMARY from "./prompt/summary.txt"
1415
import PROMPT_TITLE from "./prompt/title.txt"
1516
import { Permission } from "@/permission"
@@ -25,6 +26,9 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer"
2526
import { zod } from "@/util/effect-zod"
2627
import { withStatics, type DeepMutable } from "@/util/schema"
2728

29+
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
30+
type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string }
31+
2832
export const Info = Schema.Struct({
2933
name: Schema.String,
3034
description: Schema.optional(Schema.String),
@@ -86,6 +90,10 @@ export const layer = Layer.effect(
8690
path.join(Global.Path.tmp, "*"),
8791
...skillDirs.map((dir) => path.join(dir, "*")),
8892
]
93+
const readonlyExternalDirectory = {
94+
"*": "ask",
95+
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
96+
} satisfies Record<string, "allow" | "ask" | "deny">
8997

9098
const defaults = Permission.fromConfig({
9199
"*": "allow",
@@ -98,6 +106,8 @@ export const layer = Layer.effect(
98106
plan_enter: "deny",
99107
plan_exit: "deny",
100108
edit: "ask",
109+
repo_clone: "deny",
110+
repo_overview: "deny",
101111
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
102112
read: {
103113
"*": "allow",
@@ -175,10 +185,7 @@ export const layer = Layer.effect(
175185
webfetch: "allow",
176186
websearch: "allow",
177187
read: "allow",
178-
external_directory: {
179-
"*": "ask",
180-
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
181-
},
188+
external_directory: readonlyExternalDirectory,
182189
}),
183190
user,
184191
),
@@ -188,6 +195,33 @@ export const layer = Layer.effect(
188195
mode: "subagent",
189196
native: true,
190197
},
198+
scout: {
199+
name: "scout",
200+
permission: Permission.merge(
201+
defaults,
202+
Permission.fromConfig({
203+
"*": "deny",
204+
grep: "allow",
205+
glob: "allow",
206+
webfetch: "allow",
207+
websearch: "allow",
208+
codesearch: "allow",
209+
read: "allow",
210+
repo_clone: "allow",
211+
repo_overview: "allow",
212+
external_directory: {
213+
...readonlyExternalDirectory,
214+
[path.join(Global.Path.repos, "*")]: "allow",
215+
},
216+
}),
217+
user,
218+
),
219+
description: `Docs and dependency-source specialist. Use this when you need to inspect external documentation, clone dependency repositories into the managed cache, and research library implementation details without modifying the user's workspace.`,
220+
prompt: PROMPT_SCOUT,
221+
options: {},
222+
mode: "subagent",
223+
native: true,
224+
},
191225
compaction: {
192226
name: "compaction",
193227
mode: "primary",
@@ -265,6 +299,73 @@ export const layer = Layer.effect(
265299
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
266300
}
267301

302+
function referencePath(value: string) {
303+
if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2))
304+
return path.isAbsolute(value)
305+
? value
306+
: path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value)
307+
}
308+
309+
function resolveReference(reference: ReferenceEntry): ResolvedReference {
310+
if (typeof reference === "string") {
311+
if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) {
312+
return { kind: "local", path: referencePath(reference) }
313+
}
314+
return { kind: "git", repository: reference }
315+
}
316+
if ("path" in reference) return { kind: "local", path: referencePath(reference.path) }
317+
return { kind: "git", repository: reference.repository, branch: reference.branch }
318+
}
319+
320+
function referencePrompt(name: string, reference: ResolvedReference) {
321+
if (reference.kind === "local") {
322+
return [
323+
PROMPT_SCOUT,
324+
`You are Scout reference @${name}. This reference points to a local directory outside or alongside the current workspace.`,
325+
`Local directory: ${reference.path}`,
326+
`When invoked, inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
327+
].join("\n\n")
328+
}
329+
330+
return [
331+
PROMPT_SCOUT,
332+
`You are Scout reference @${name}. This reference points to a git repository.`,
333+
`Repository: ${reference.repository}`,
334+
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
335+
`When invoked, clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files.`,
336+
].join("\n\n")
337+
}
338+
339+
for (const [name, reference] of Object.entries(cfg.reference ?? {})) {
340+
if (agents[name]) continue
341+
const resolved = resolveReference(reference)
342+
const localPath = resolved.kind === "local" ? resolved.path : undefined
343+
agents[name] = {
344+
name,
345+
description:
346+
resolved.kind === "local"
347+
? `Scout reference for local directory ${resolved.path}`
348+
: `Scout reference for repository ${resolved.repository}`,
349+
permission: Permission.merge(
350+
agents.scout.permission,
351+
Permission.fromConfig(
352+
localPath
353+
? {
354+
external_directory: {
355+
[localPath]: "allow",
356+
[path.join(localPath, "*")]: "allow",
357+
},
358+
}
359+
: {},
360+
),
361+
),
362+
prompt: referencePrompt(name, resolved),
363+
options: { reference },
364+
mode: "subagent",
365+
native: false,
366+
}
367+
}
368+
268369
// Ensure Truncate.GLOB is allowed unless explicitly configured
269370
for (const name in agents) {
270371
const agent = agents[name]
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
You are `scout`, a read-only research agent for external libraries, dependency source, and documentation.
2+
3+
Your purpose is to investigate code outside the local workspace and return evidence-backed findings without modifying the user's workspace.
4+
5+
Use this agent when asked to:
6+
- inspect dependency repositories or library source
7+
- compare local code against upstream implementations
8+
- research public GitHub repositories the environment can clone
9+
- explain how a library or framework works by reading its source and docs
10+
- investigate third-party APIs, workflows, or behavior outside the current workspace
11+
12+
Working style:
13+
1. When the task involves a GitHub repository or dependency source, use `repo_clone` first.
14+
2. After cloning, use `Glob`, `Grep`, and `Read` to inspect the cloned repository.
15+
3. Use `WebFetch` for official documentation pages when source alone is not enough.
16+
4. Prefer direct code and documentation evidence over assumptions.
17+
5. If multiple external repositories are relevant, inspect each one before drawing conclusions.
18+
19+
Research standards:
20+
- cite exact absolute file paths and line references whenever possible
21+
- separate what is verified from what is inferred
22+
- if the answer depends on branch state, note that you are reading the repository's current default clone state unless the caller specifies otherwise
23+
- if a repository cannot be cloned or accessed, say so explicitly and continue with whatever evidence is still available
24+
- call out uncertainty clearly instead of smoothing over gaps
25+
26+
Output expectations:
27+
- start with the direct answer
28+
- then explain the evidence repository by repository or source by source
29+
- include file references when relevant
30+
- keep the explanation organized and easy to scan
31+
32+
Constraints:
33+
- do not modify files or run tools that change the user's workspace
34+
- return absolute file paths for cloned-repo findings in your final response
35+
36+
Complete the user's research request efficiently and report your findings clearly.

packages/opencode/src/cli/cmd/github.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { AppRuntime } from "@/effect/app-runtime"
3333
import { Git } from "@/git"
3434
import { setTimeout as sleep } from "node:timers/promises"
3535
import { Process } from "@/util/process"
36+
import { parseGitHubRemote } from "@/util/repository"
3637
import { Effect } from "effect"
3738

3839
type GitHubAuthor = {
@@ -152,18 +153,7 @@ const SUPPORTED_EVENTS = [...USER_EVENTS, ...REPO_EVENTS] as const
152153
type UserEvent = (typeof USER_EVENTS)[number]
153154
type RepoEvent = (typeof REPO_EVENTS)[number]
154155

155-
// Parses GitHub remote URLs in various formats:
156-
// - https://github.com/owner/repo.git
157-
// - https://github.com/owner/repo
158-
// - git@github.com:owner/repo.git
159-
// - git@github.com:owner/repo
160-
// - ssh://git@github.com/owner/repo.git
161-
// - ssh://git@github.com/owner/repo
162-
export function parseGitHubRemote(url: string): { owner: string; repo: string } | null {
163-
const match = url.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/)
164-
if (!match) return null
165-
return { owner: match[1], repo: match[2] }
166-
}
156+
export { parseGitHubRemote }
167157

168158
/**
169159
* Extracts displayable text from assistant response parts.

packages/opencode/src/config/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { ConfigPaths } from "./paths"
3939
import { ConfigPermission } from "./permission"
4040
import { ConfigPlugin } from "./plugin"
4141
import { ConfigProvider } from "./provider"
42+
import { ConfigReference } from "./reference"
4243
import { ConfigServer } from "./server"
4344
import { ConfigSkills } from "./skills"
4445
import { ConfigVariable } from "./variable"
@@ -114,6 +115,9 @@ export const Info = Schema.Struct({
114115
description: "Command configuration, see https://opencode.ai/docs/commands",
115116
}),
116117
skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }),
118+
reference: Schema.optional(ConfigReference.Info).annotate({
119+
description: "Named git or local directory references that can be @ mentioned as Scout-backed subagents",
120+
}),
117121
watcher: Schema.optional(
118122
Schema.Struct({
119123
ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
@@ -173,6 +177,7 @@ export const Info = Schema.Struct({
173177
// subagent
174178
general: Schema.optional(ConfigAgent.Info),
175179
explore: Schema.optional(ConfigAgent.Info),
180+
scout: Schema.optional(ConfigAgent.Info),
176181
// specialized
177182
title: Schema.optional(ConfigAgent.Info),
178183
summary: Schema.optional(ConfigAgent.Info),

packages/opencode/src/config/permission.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ const InputObject = Schema.StructWithRest(
3535
question: Schema.optional(Action),
3636
webfetch: Schema.optional(Action),
3737
websearch: Schema.optional(Action),
38+
codesearch: Schema.optional(Action),
39+
repo_clone: Schema.optional(Rule),
40+
repo_overview: Schema.optional(Rule),
3841
lsp: Schema.optional(Rule),
3942
doom_loop: Schema.optional(Action),
4043
skill: Schema.optional(Rule),
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export * as ConfigReference from "./reference"
2+
3+
import { Schema } from "effect"
4+
import { zod } from "@/util/effect-zod"
5+
import { withStatics } from "@/util/schema"
6+
7+
const Git = Schema.Struct({
8+
repository: Schema.String.annotate({
9+
description: "Git repository URL, host/path reference, or GitHub owner/repo shorthand",
10+
}),
11+
branch: Schema.optional(Schema.String).annotate({
12+
description: "Branch or ref Scout should clone and inspect",
13+
}),
14+
})
15+
16+
const Local = Schema.Struct({
17+
path: Schema.String.annotate({
18+
description: "Absolute path, ~/ path, or workspace-relative path to a local reference directory",
19+
}),
20+
})
21+
22+
export const Entry = Schema.Union([Schema.String, Git, Local]).annotate({ identifier: "ReferenceConfigEntry" })
23+
24+
export const Info = Schema.Record(Schema.String, Entry)
25+
.annotate({ identifier: "ReferenceConfig" })
26+
.pipe(withStatics((s) => ({ zod: zod(s) })))
27+
export type Info = Schema.Schema.Type<typeof Info>

0 commit comments

Comments
 (0)