Skip to content

Commit 7f05e21

Browse files
Apply PR #24149: feat(core): add scout agent for repo research
2 parents b5f27e8 + b633a8b commit 7f05e21

36 files changed

Lines changed: 1166 additions & 50 deletions

packages/opencode/src/acp/agent.ts

Lines changed: 6 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,10 @@ 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"] }] : []
15901596
default:
15911597
return []
15921598
}

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",
@@ -94,6 +99,8 @@ export const layer = Layer.effect(
9499
plan_enter: "deny",
95100
plan_exit: "deny",
96101
edit: "ask",
102+
repo_clone: "deny",
103+
repo_overview: "deny",
97104
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
98105
read: {
99106
"*": "allow",
@@ -172,10 +179,7 @@ export const layer = Layer.effect(
172179
websearch: "allow",
173180
codesearch: "allow",
174181
read: "allow",
175-
external_directory: {
176-
"*": "ask",
177-
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
178-
},
182+
external_directory: readonlyExternalDirectory,
179183
}),
180184
user,
181185
),
@@ -185,6 +189,33 @@ export const layer = Layer.effect(
185189
mode: "subagent",
186190
native: true,
187191
},
192+
scout: {
193+
name: "scout",
194+
permission: Permission.merge(
195+
defaults,
196+
Permission.fromConfig({
197+
"*": "deny",
198+
grep: "allow",
199+
glob: "allow",
200+
webfetch: "allow",
201+
websearch: "allow",
202+
codesearch: "allow",
203+
read: "allow",
204+
repo_clone: "allow",
205+
repo_overview: "allow",
206+
external_directory: {
207+
...readonlyExternalDirectory,
208+
[path.join(Global.Path.data, "repos", "*")]: "allow",
209+
},
210+
}),
211+
user,
212+
),
213+
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.`,
214+
prompt: PROMPT_SCOUT,
215+
options: {},
216+
mode: "subagent",
217+
native: true,
218+
},
188219
compaction: {
189220
name: "compaction",
190221
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
@@ -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: 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
@@ -25,6 +25,8 @@ import { WebSearchTool } from "./websearch"
2525
import { CodeSearchTool } from "./codesearch"
2626
import { Flag } from "@opencode-ai/core/flag/flag"
2727
import * as Log from "@opencode-ai/core/util/log"
28+
import { RepoCloneTool } from "./repo_clone"
29+
import { RepoOverviewTool } from "./repo_overview"
2830
import { LspTool } from "./lsp"
2931
import * as Truncate from "./truncate"
3032
import { ApplyPatchTool } from "./apply_patch"
@@ -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),
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import path from "path"
2+
import { Effect, Schema } from "effect"
3+
import { AppFileSystem } from "@opencode-ai/core/filesystem"
4+
import { Flock } from "@opencode-ai/core/util/flock"
5+
import { Git } from "@/git"
6+
import DESCRIPTION from "./repo_clone.txt"
7+
import * as Tool from "./tool"
8+
import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository"
9+
10+
export const Parameters = Schema.Struct({
11+
repository: Schema.String.annotate({
12+
description: "Repository to clone, as a git URL, host/path reference, or GitHub owner/repo shorthand",
13+
}),
14+
refresh: Schema.optional(Schema.Boolean).annotate({
15+
description: "When true, fetches the latest remote state into the managed cache",
16+
}),
17+
})
18+
19+
type Metadata = {
20+
repository: string
21+
host: string
22+
remote: string
23+
localPath: string
24+
status: "cached" | "cloned" | "refreshed"
25+
head?: string
26+
branch?: string
27+
}
28+
29+
function statusForRepository(input: { reuse: boolean; refresh?: boolean }) {
30+
if (!input.reuse) return "cloned" as const
31+
if (input.refresh) return "refreshed" as const
32+
return "cached" as const
33+
}
34+
35+
function resetTarget(input: {
36+
remoteHead: { code: number; stdout: string }
37+
branch: { code: number; stdout: string }
38+
}) {
39+
if (input.remoteHead.code === 0 && input.remoteHead.stdout) {
40+
return input.remoteHead.stdout.replace(/^refs\/remotes\//, "")
41+
}
42+
if (input.branch.code === 0 && input.branch.stdout) {
43+
return `origin/${input.branch.stdout}`
44+
}
45+
return "HEAD"
46+
}
47+
48+
export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSystem.Service | Git.Service>(
49+
"repo_clone",
50+
Effect.gen(function* () {
51+
const fs = yield* AppFileSystem.Service
52+
const git = yield* Git.Service
53+
54+
return {
55+
description: DESCRIPTION,
56+
parameters: Parameters,
57+
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
58+
Effect.gen(function* () {
59+
const reference = parseRepositoryReference(params.repository)
60+
if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand")
61+
62+
const repository = reference.label
63+
const remote = reference.remote
64+
const localPath = repositoryCachePath(reference)
65+
const cloneTarget = parseRepositoryReference(remote) ?? reference
66+
67+
yield* ctx.ask({
68+
permission: "repo_clone",
69+
patterns: [repository],
70+
always: [repository],
71+
metadata: {
72+
repository,
73+
remote,
74+
path: localPath,
75+
refresh: Boolean(params.refresh),
76+
},
77+
})
78+
79+
return yield* Effect.acquireUseRelease(
80+
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })),
81+
() =>
82+
Effect.gen(function* () {
83+
yield* fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie)
84+
85+
const exists = yield* fs.existsSafe(localPath)
86+
const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
87+
const origin = hasGitDir
88+
? yield* git.run(["config", "--get", "remote.origin.url"], { cwd: localPath })
89+
: undefined
90+
const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
91+
const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
92+
if (exists && !reuse) {
93+
yield* fs.remove(localPath, { recursive: true }).pipe(Effect.orDie)
94+
}
95+
96+
const status = statusForRepository({ reuse, refresh: params.refresh })
97+
98+
if (status === "cloned") {
99+
const clone = yield* git.run(["clone", "--depth", "100", remote, localPath], { cwd: path.dirname(localPath) })
100+
if (clone.exitCode !== 0) {
101+
throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`)
102+
}
103+
}
104+
105+
if (status === "refreshed") {
106+
const fetch = yield* git.run(["fetch", "--all", "--prune"], { cwd: localPath })
107+
if (fetch.exitCode !== 0) {
108+
throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`)
109+
}
110+
111+
const remoteHead = yield* git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath })
112+
const branch = yield* git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath })
113+
const target = resetTarget({
114+
remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() },
115+
branch: { code: branch.exitCode, stdout: branch.text().trim() },
116+
})
117+
118+
const reset = yield* git.run(["reset", "--hard", target], { cwd: localPath })
119+
if (reset.exitCode !== 0) {
120+
throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`)
121+
}
122+
}
123+
124+
const head = yield* git.run(["rev-parse", "HEAD"], { cwd: localPath })
125+
const branch = yield* git.branch(localPath)
126+
const headText = head.exitCode === 0 ? head.text().trim() : undefined
127+
128+
return {
129+
title: repository,
130+
metadata: {
131+
repository,
132+
host: reference.host,
133+
remote,
134+
localPath,
135+
status,
136+
head: headText,
137+
branch,
138+
},
139+
output: [
140+
`Repository ready: ${repository}`,
141+
`Status: ${status}`,
142+
`Local path: ${localPath}`,
143+
...(branch ? [`Branch: ${branch}`] : []),
144+
...(headText ? [`HEAD: ${headText}`] : []),
145+
].join("\n"),
146+
}
147+
}),
148+
(lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore),
149+
)
150+
}).pipe(Effect.orDie),
151+
} satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
152+
}),
153+
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
- Clone or refresh a repository into OpenCode's managed cache under the data directory
2+
- Accepts git URLs, forge host/path references, or GitHub owner/repo shorthand
3+
- Returns the cached absolute local path so other tools can explore the cloned source
4+
- Use this before Read, Glob, or Grep when the code you need lives outside the current workspace
5+
- This tool is intended for dependency and documentation research workflows, not for modifying the user's workspace

0 commit comments

Comments
 (0)