Skip to content

Commit 6967b71

Browse files
Apply PR #24149: feat(core): add scout agent for repo research
2 parents fbf823d + b633a8b commit 6967b71

39 files changed

Lines changed: 1174 additions & 50 deletions

packages/core/src/global.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const paths = {
1818
data,
1919
bin: path.join(cache, "bin"),
2020
log: path.join(data, "log"),
21+
repos: path.join(data, "repos"),
2122
cache,
2223
config,
2324
state,
@@ -33,6 +34,7 @@ await Promise.all([
3334
fs.mkdir(Path.state, { recursive: true }),
3435
fs.mkdir(Path.log, { recursive: true }),
3536
fs.mkdir(Path.bin, { recursive: true }),
37+
fs.mkdir(Path.repos, { recursive: true }),
3638
])
3739

3840
export class Service extends Context.Service<Service, Interface>()("@opencode/Global") {}
@@ -45,6 +47,7 @@ export interface Interface {
4547
readonly state: string
4648
readonly bin: string
4749
readonly log: string
50+
readonly repos: string
4851
}
4952

5053
export const layer = Layer.effect(
@@ -58,6 +61,7 @@ export const layer = Layer.effect(
5861
state: Path.state,
5962
bin: Path.bin,
6063
log: Path.log,
64+
repos: Path.repos,
6165
})
6266
}),
6367
)

packages/core/test/fixture/effect-flock-worker.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const testGlobal = Layer.succeed(
3030
state: os.tmpdir(),
3131
bin: os.tmpdir(),
3232
log: os.tmpdir(),
33+
repos: os.tmpdir(),
3334
}),
3435
)
3536

packages/core/test/util/effect-flock.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ const testGlobal = Layer.succeed(
103103
state: os.tmpdir(),
104104
bin: os.tmpdir(),
105105
log: os.tmpdir(),
106+
repos: os.tmpdir(),
106107
}),
107108
)
108109

packages/opencode/src/acp/agent.ts

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

15641564
case "grep":
15651565
case "glob":
1566+
case "repo_clone":
1567+
case "repo_overview":
15661568
case "context7_resolve_library_id":
15671569
case "context7_get_library_docs":
15681570
return "search"
@@ -1587,6 +1589,12 @@ function toLocations(toolName: string, input: Record<string, any>): { path: stri
15871589
case "glob":
15881590
case "grep":
15891591
return input["path"] ? [{ path: input["path"] }] : []
1592+
case "repo_clone":
1593+
return input["path"] ? [{ path: input["path"] }] : []
1594+
case "repo_overview":
1595+
return input["path"] ? [{ path: input["path"] }] : []
1596+
case "bash":
1597+
return []
15901598
default:
15911599
return []
15921600
}

packages/opencode/src/agent/agent.ts

Lines changed: 35 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"
@@ -82,6 +83,10 @@ export const layer = Layer.effect(
8283
const cfg = yield* config.get()
8384
const skillDirs = yield* skill.dirs()
8485
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
86+
const readonlyExternalDirectory = {
87+
"*": "ask",
88+
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
89+
} satisfies Record<string, "allow" | "ask" | "deny">
8590

8691
const defaults = Permission.fromConfig({
8792
"*": "allow",
@@ -93,6 +98,8 @@ export const layer = Layer.effect(
9398
question: "deny",
9499
plan_enter: "deny",
95100
plan_exit: "deny",
101+
repo_clone: "deny",
102+
repo_overview: "deny",
96103
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
97104
read: {
98105
"*": "allow",
@@ -171,10 +178,7 @@ export const layer = Layer.effect(
171178
websearch: "allow",
172179
codesearch: "allow",
173180
read: "allow",
174-
external_directory: {
175-
"*": "ask",
176-
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
177-
},
181+
external_directory: readonlyExternalDirectory,
178182
}),
179183
user,
180184
),
@@ -184,6 +188,33 @@ export const layer = Layer.effect(
184188
mode: "subagent",
185189
native: true,
186190
},
191+
scout: {
192+
name: "scout",
193+
permission: Permission.merge(
194+
defaults,
195+
Permission.fromConfig({
196+
"*": "deny",
197+
grep: "allow",
198+
glob: "allow",
199+
webfetch: "allow",
200+
websearch: "allow",
201+
codesearch: "allow",
202+
read: "allow",
203+
repo_clone: "allow",
204+
repo_overview: "allow",
205+
external_directory: {
206+
...readonlyExternalDirectory,
207+
[path.join(Global.Path.repos, "*")]: "allow",
208+
},
209+
}),
210+
user,
211+
),
212+
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.`,
213+
prompt: PROMPT_SCOUT,
214+
options: {},
215+
mode: "subagent",
216+
native: true,
217+
},
187218
compaction: {
188219
name: "compaction",
189220
mode: "primary",
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
@@ -32,6 +32,7 @@ import { SessionPrompt } from "@/session/prompt"
3232
import { AppRuntime } from "@/effect/app-runtime"
3333
import { Git } from "@/git"
3434
import { setTimeout as sleep } from "node:timers/promises"
35+
import { parseGitHubRemote } from "@/util/repository"
3536
import { Process } from "@/util/process"
3637
import { Effect } from "effect"
3738

@@ -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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ export const Info = Schema.Struct({
169169
// subagent
170170
general: Schema.optional(ConfigAgent.Info),
171171
explore: Schema.optional(ConfigAgent.Info),
172+
scout: Schema.optional(ConfigAgent.Info),
172173
// specialized
173174
title: Schema.optional(ConfigAgent.Info),
174175
summary: Schema.optional(ConfigAgent.Info),

packages/opencode/src/config/permission.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ const InputObject = Schema.StructWithRest(
3636
webfetch: Schema.optional(Action),
3737
websearch: Schema.optional(Action),
3838
codesearch: Schema.optional(Action),
39+
repo_clone: Schema.optional(Rule),
40+
repo_overview: Schema.optional(Rule),
3941
lsp: Schema.optional(Rule),
4042
doom_loop: Schema.optional(Action),
4143
skill: Schema.optional(Rule),

packages/opencode/src/tool/registry.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import { Provider } from "@/provider/provider"
2323
import { ProviderID, type ModelID } from "../provider/schema"
2424
import { WebSearchTool } from "./websearch"
2525
import { CodeSearchTool } from "./codesearch"
26+
import { RepoCloneTool } from "./repo_clone"
27+
import { RepoOverviewTool } from "./repo_overview"
2628
import { Flag } from "@opencode-ai/core/flag/flag"
2729
import * as Log from "@opencode-ai/core/util/log"
2830
import { LspTool } from "./lsp"
@@ -45,6 +47,7 @@ import { Instruction } from "../session/instruction"
4547
import { AppFileSystem } from "@opencode-ai/core/filesystem"
4648
import { Bus } from "../bus"
4749
import { Agent } from "../agent/agent"
50+
import { Git } from "@/git"
4851
import { Skill } from "../skill"
4952
import { Permission } from "@/permission"
5053

@@ -80,6 +83,7 @@ export const layer: Layer.Layer<
8083
| Skill.Service
8184
| Session.Service
8285
| Provider.Service
86+
| Git.Service
8387
| LSP.Service
8488
| Instruction.Service
8589
| AppFileSystem.Service
@@ -109,6 +113,8 @@ export const layer: Layer.Layer<
109113
const websearch = yield* WebSearchTool
110114
const shell = yield* ShellTool
111115
const codesearch = yield* CodeSearchTool
116+
const repoClone = yield* RepoCloneTool
117+
const repoOverview = yield* RepoOverviewTool
112118
const globtool = yield* GlobTool
113119
const writetool = yield* WriteTool
114120
const edit = yield* EditTool
@@ -199,6 +205,8 @@ export const layer: Layer.Layer<
199205
todo: Tool.init(todo),
200206
search: Tool.init(websearch),
201207
code: Tool.init(codesearch),
208+
repo_clone: Tool.init(repoClone),
209+
repo_overview: Tool.init(repoOverview),
202210
skill: Tool.init(skilltool),
203211
patch: Tool.init(patchtool),
204212
question: Tool.init(question),
@@ -222,6 +230,8 @@ export const layer: Layer.Layer<
222230
tool.todo,
223231
tool.search,
224232
tool.code,
233+
tool.repo_clone,
234+
tool.repo_overview,
225235
tool.skill,
226236
tool.patch,
227237
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []),
@@ -336,6 +346,7 @@ export const defaultLayer = Layer.suspend(() =>
336346
Layer.provide(Agent.defaultLayer),
337347
Layer.provide(Session.defaultLayer),
338348
Layer.provide(Provider.defaultLayer),
349+
Layer.provide(Git.defaultLayer),
339350
Layer.provide(LSP.defaultLayer),
340351
Layer.provide(Instruction.defaultLayer),
341352
Layer.provide(AppFileSystem.defaultLayer),

0 commit comments

Comments
 (0)