Skip to content

Commit 592880e

Browse files
Apply PR #24149: feat(core): add scout agent for repo research
2 parents 4734f64 + b633a8b commit 592880e

36 files changed

Lines changed: 1167 additions & 50 deletions

packages/opencode/src/acp/agent.ts

Lines changed: 6 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"
@@ -1588,6 +1590,10 @@ function toLocations(toolName: string, input: Record<string, any>): { path: stri
15881590
case "glob":
15891591
case "grep":
15901592
return input["path"] ? [{ path: input["path"] }] : []
1593+
case "repo_clone":
1594+
return input["path"] ? [{ path: input["path"] }] : []
1595+
case "repo_overview":
1596+
return input["path"] ? [{ path: input["path"] }] : []
15911597
case ShellToolID.id:
15921598
return []
15931599
default:

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",
@@ -170,10 +177,7 @@ export const layer = Layer.effect(
170177
webfetch: "allow",
171178
websearch: "allow",
172179
read: "allow",
173-
external_directory: {
174-
"*": "ask",
175-
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
176-
},
180+
external_directory: readonlyExternalDirectory,
177181
}),
178182
user,
179183
),
@@ -183,6 +187,33 @@ export const layer = Layer.effect(
183187
mode: "subagent",
184188
native: true,
185189
},
190+
scout: {
191+
name: "scout",
192+
permission: Permission.merge(
193+
defaults,
194+
Permission.fromConfig({
195+
"*": "deny",
196+
grep: "allow",
197+
glob: "allow",
198+
webfetch: "allow",
199+
websearch: "allow",
200+
codesearch: "allow",
201+
read: "allow",
202+
repo_clone: "allow",
203+
repo_overview: "allow",
204+
external_directory: {
205+
...readonlyExternalDirectory,
206+
[path.join(Global.Path.data, "repos", "*")]: "allow",
207+
},
208+
}),
209+
user,
210+
),
211+
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.`,
212+
prompt: PROMPT_SCOUT,
213+
options: {},
214+
mode: "subagent",
215+
native: true,
216+
},
186217
compaction: {
187218
name: "compaction",
188219
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-
// - [email protected]:owner/repo.git
159-
// - [email protected]:owner/repo
160-
// - ssh://[email protected]/owner/repo.git
161-
// - ssh://[email protected]/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
@@ -168,6 +168,7 @@ export const Info = Schema.Struct({
168168
// subagent
169169
general: Schema.optional(ConfigAgent.Info),
170170
explore: Schema.optional(ConfigAgent.Info),
171+
scout: Schema.optional(ConfigAgent.Info),
171172
// specialized
172173
title: Schema.optional(ConfigAgent.Info),
173174
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),

packages/opencode/src/tool/registry.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import { Plugin } from "../plugin"
2222
import { Provider } from "@/provider/provider"
2323
import { ProviderID, type ModelID } from "../provider/schema"
2424
import { WebSearchTool } from "./websearch"
25+
import { RepoCloneTool } from "./repo_clone"
26+
import { RepoOverviewTool } from "./repo_overview"
2527
import { Flag } from "@opencode-ai/core/flag/flag"
2628
import * as Log from "@opencode-ai/core/util/log"
2729
import { LspTool } from "./lsp"
@@ -44,6 +46,7 @@ import { Instruction } from "../session/instruction"
4446
import { AppFileSystem } from "@opencode-ai/core/filesystem"
4547
import { Bus } from "../bus"
4648
import { Agent } from "../agent/agent"
49+
import { Git } from "@/git"
4750
import { Skill } from "../skill"
4851
import { Permission } from "@/permission"
4952

@@ -79,6 +82,7 @@ export const layer: Layer.Layer<
7982
| Skill.Service
8083
| Session.Service
8184
| Provider.Service
85+
| Git.Service
8286
| LSP.Service
8387
| Instruction.Service
8488
| AppFileSystem.Service
@@ -107,6 +111,8 @@ export const layer: Layer.Layer<
107111
const webfetch = yield* WebFetchTool
108112
const websearch = yield* WebSearchTool
109113
const shell = yield* ShellTool
114+
const repoClone = yield* RepoCloneTool
115+
const repoOverview = yield* RepoOverviewTool
110116
const globtool = yield* GlobTool
111117
const writetool = yield* WriteTool
112118
const edit = yield* EditTool
@@ -196,6 +202,8 @@ export const layer: Layer.Layer<
196202
fetch: Tool.init(webfetch),
197203
todo: Tool.init(todo),
198204
search: Tool.init(websearch),
205+
repo_clone: Tool.init(repoClone),
206+
repo_overview: Tool.init(repoOverview),
199207
skill: Tool.init(skilltool),
200208
patch: Tool.init(patchtool),
201209
question: Tool.init(question),
@@ -218,6 +226,8 @@ export const layer: Layer.Layer<
218226
tool.fetch,
219227
tool.todo,
220228
tool.search,
229+
tool.repo_clone,
230+
tool.repo_overview,
221231
tool.skill,
222232
tool.patch,
223233
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []),
@@ -332,6 +342,7 @@ export const defaultLayer = Layer.suspend(() =>
332342
Layer.provide(Agent.defaultLayer),
333343
Layer.provide(Session.defaultLayer),
334344
Layer.provide(Provider.defaultLayer),
345+
Layer.provide(Git.defaultLayer),
335346
Layer.provide(LSP.defaultLayer),
336347
Layer.provide(Instruction.defaultLayer),
337348
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)