mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
sync
This commit is contained in:
parent
6adb22d290
commit
0dad18f1e5
3 changed files with 167 additions and 148 deletions
|
|
@ -4,10 +4,6 @@ import { Provider } from "../provider/provider"
|
|||
import { generateObject, type ModelMessage } from "ai"
|
||||
import { SystemPrompt } from "../session/system"
|
||||
import { Instance } from "../project/instance"
|
||||
import { mergeDeep } from "remeda"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
const log = Log.create({ service: "agent" })
|
||||
|
||||
import PROMPT_GENERATE from "./generate.txt"
|
||||
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
||||
|
|
@ -15,6 +11,7 @@ import PROMPT_EXPLORE from "./prompt/explore.txt"
|
|||
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { mergeDeep } from "remeda"
|
||||
|
||||
export namespace Agent {
|
||||
export const Info = z
|
||||
|
|
@ -24,7 +21,6 @@ export namespace Agent {
|
|||
mode: z.enum(["subagent", "primary", "all"]),
|
||||
native: z.boolean().optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
default: z.boolean().optional(),
|
||||
topP: z.number().optional(),
|
||||
temperature: z.number().optional(),
|
||||
color: z.string().optional(),
|
||||
|
|
@ -37,7 +33,7 @@ export namespace Agent {
|
|||
.optional(),
|
||||
prompt: z.string().optional(),
|
||||
options: z.record(z.string(), z.any()),
|
||||
maxSteps: z.number().int().positive().optional(),
|
||||
steps: z.number().int().positive().optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "Agent",
|
||||
|
|
@ -46,29 +42,13 @@ export namespace Agent {
|
|||
|
||||
const state = Instance.state(async () => {
|
||||
const cfg = await Config.get()
|
||||
const defaultTools = cfg.tools ?? {}
|
||||
|
||||
const permission: PermissionNext.Ruleset = mergeDeep(
|
||||
{
|
||||
"*": {
|
||||
"*": "allow",
|
||||
},
|
||||
edit: {
|
||||
"*": "allow",
|
||||
},
|
||||
bash: {
|
||||
"*": "allow",
|
||||
},
|
||||
webfetch: {
|
||||
"*": "allow",
|
||||
},
|
||||
doom_loop: {
|
||||
"*": "ask",
|
||||
},
|
||||
external_directory: {
|
||||
"*": "ask",
|
||||
},
|
||||
},
|
||||
const permission: PermissionNext.Ruleset = PermissionNext.merge(
|
||||
PermissionNext.fromConfig({
|
||||
"*": "allow",
|
||||
doom_loop: "ask",
|
||||
external_directory: "ask",
|
||||
}),
|
||||
PermissionNext.fromConfig(cfg.permission ?? {}),
|
||||
)
|
||||
|
||||
|
|
@ -83,19 +63,22 @@ export namespace Agent {
|
|||
plan: {
|
||||
name: "plan",
|
||||
options: {},
|
||||
permission: mergeDeep(permission, {
|
||||
edit: {
|
||||
"*": "deny",
|
||||
".opencode/plan/*": "allow",
|
||||
},
|
||||
}),
|
||||
permission: PermissionNext.merge(
|
||||
permission,
|
||||
PermissionNext.fromConfig({
|
||||
edit: {
|
||||
"*": "deny",
|
||||
".opencode/plan/*.md": "allow",
|
||||
},
|
||||
}),
|
||||
),
|
||||
mode: "primary",
|
||||
native: true,
|
||||
},
|
||||
general: {
|
||||
name: "general",
|
||||
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
|
||||
permission: mergeDeep(
|
||||
permission: PermissionNext.merge(
|
||||
permission,
|
||||
PermissionNext.fromConfig({
|
||||
todoread: "deny",
|
||||
|
|
@ -109,13 +92,18 @@ export namespace Agent {
|
|||
},
|
||||
explore: {
|
||||
name: "explore",
|
||||
permission: mergeDeep(
|
||||
permission: PermissionNext.merge(
|
||||
permission,
|
||||
PermissionNext.fromConfig({
|
||||
todoread: "deny",
|
||||
todowrite: "deny",
|
||||
edit: "deny",
|
||||
write: "deny",
|
||||
"*": "deny",
|
||||
grep: "allow",
|
||||
glob: "allow",
|
||||
list: "allow",
|
||||
bash: "allow",
|
||||
webfetch: "allow",
|
||||
websearch: "allow",
|
||||
codesearch: "allow",
|
||||
read: "allow",
|
||||
}),
|
||||
),
|
||||
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
|
||||
|
|
@ -169,65 +157,22 @@ export namespace Agent {
|
|||
item = result[key] = {
|
||||
name: key,
|
||||
mode: "all",
|
||||
permission: permission,
|
||||
permission,
|
||||
options: {},
|
||||
native: false,
|
||||
}
|
||||
const {
|
||||
name,
|
||||
model,
|
||||
prompt,
|
||||
tools,
|
||||
description,
|
||||
temperature,
|
||||
top_p,
|
||||
mode,
|
||||
permission,
|
||||
color,
|
||||
maxSteps,
|
||||
...extra
|
||||
} = value
|
||||
item.options = {
|
||||
...item.options,
|
||||
...extra,
|
||||
}
|
||||
if (model) item.model = Provider.parseModel(model)
|
||||
if (prompt) item.prompt = prompt
|
||||
if (tools)
|
||||
item.tools = {
|
||||
...item.tools,
|
||||
...tools,
|
||||
}
|
||||
item.tools = {
|
||||
...defaultTools,
|
||||
...item.tools,
|
||||
}
|
||||
if (description) item.description = description
|
||||
if (temperature != undefined) item.temperature = temperature
|
||||
if (top_p != undefined) item.topP = top_p
|
||||
if (mode) item.mode = mode
|
||||
if (color) item.color = color
|
||||
// just here for consistency & to prevent it from being added as an option
|
||||
if (name) item.name = name
|
||||
if (maxSteps != undefined) item.maxSteps = maxSteps
|
||||
|
||||
if (permission ?? cfg.permission) {
|
||||
item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {})
|
||||
}
|
||||
if (value.model) item.model = Provider.parseModel(value.model)
|
||||
item.prompt = value.prompt ?? item.prompt
|
||||
item.description = value.description ?? item.description
|
||||
item.temperature = value.temperature ?? item.temperature
|
||||
item.topP = value.top_p ?? item.topP
|
||||
item.mode = value.mode ?? item.mode
|
||||
item.color = value.color ?? item.color
|
||||
item.name = value.options?.name ?? item.name
|
||||
item.steps = value.steps ?? item.steps
|
||||
item.options = mergeDeep(item.options, value.options ?? {})
|
||||
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
|
||||
}
|
||||
|
||||
// Mark the default agent
|
||||
const defaultName = cfg.default_agent ?? "build"
|
||||
const defaultCandidate = result[defaultName]
|
||||
if (defaultCandidate && defaultCandidate.mode !== "subagent") {
|
||||
defaultCandidate.default = true
|
||||
} else {
|
||||
// Fall back to "build" if configured default is invalid
|
||||
if (result["build"]) {
|
||||
result["build"].default = true
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
|
|
@ -239,12 +184,6 @@ export namespace Agent {
|
|||
return state().then((x) => Object.values(x))
|
||||
}
|
||||
|
||||
export async function defaultAgent(): Promise<string> {
|
||||
const agents = await state()
|
||||
const defaultCandidate = Object.values(agents).find((a) => a.default)
|
||||
return defaultCandidate?.name ?? "build"
|
||||
}
|
||||
|
||||
export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) {
|
||||
const cfg = await Config.get()
|
||||
const defaultModel = input.model ?? (await Provider.defaultModel())
|
||||
|
|
@ -283,42 +222,3 @@ export namespace Agent {
|
|||
return result.object
|
||||
}
|
||||
}
|
||||
|
||||
function mergeAgentPermissions(basePermission: any, overridePermission: any): Agent.Info["permission"] {
|
||||
if (typeof basePermission.bash === "string") {
|
||||
basePermission.bash = {
|
||||
"*": basePermission.bash,
|
||||
}
|
||||
}
|
||||
if (typeof overridePermission.bash === "string") {
|
||||
overridePermission.bash = {
|
||||
"*": overridePermission.bash,
|
||||
}
|
||||
}
|
||||
const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any
|
||||
let mergedBash
|
||||
if (merged.bash) {
|
||||
if (typeof merged.bash === "string") {
|
||||
mergedBash = {
|
||||
"*": merged.bash,
|
||||
}
|
||||
} else if (typeof merged.bash === "object") {
|
||||
mergedBash = mergeDeep(
|
||||
{
|
||||
"*": "allow",
|
||||
},
|
||||
merged.bash,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const result: Agent.Info["permission"] = {
|
||||
edit: merged.edit ?? "allow",
|
||||
webfetch: merged.webfetch ?? "allow",
|
||||
bash: mergedBash ?? { "*": "allow" },
|
||||
doom_loop: merged.doom_loop,
|
||||
external_directory: merged.external_directory,
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Config } from "@/config/config"
|
|||
import { Identifier } from "@/id/id"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import z from "zod"
|
||||
|
||||
export namespace PermissionNext {
|
||||
|
|
@ -31,6 +32,24 @@ export namespace PermissionNext {
|
|||
return ruleset
|
||||
}
|
||||
|
||||
export function merge(...rulesets: Ruleset[]): Ruleset {
|
||||
const result: Ruleset = {}
|
||||
for (const ruleset of rulesets) {
|
||||
for (const [permission, rule] of Object.entries(ruleset)) {
|
||||
result[permission] ??= {}
|
||||
for (const [pattern, action] of Object.entries(rule)) {
|
||||
for (const existing of Object.keys(result[permission])) {
|
||||
if (Wildcard.match(existing, pattern)) {
|
||||
delete result[permission][existing]
|
||||
}
|
||||
}
|
||||
result[permission][pattern] = action
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const Request = z
|
||||
.object({
|
||||
id: Identifier.schema("permission"),
|
||||
|
|
@ -117,14 +136,23 @@ export namespace PermissionNext {
|
|||
},
|
||||
)
|
||||
|
||||
export const evaluate = fn(
|
||||
z.object({
|
||||
permission: z.string(),
|
||||
pattern: z.string(),
|
||||
rules: Config.Permission.array(),
|
||||
}),
|
||||
async (input) => {},
|
||||
)
|
||||
export const Action = z.enum(["allow", "deny", "ask"])
|
||||
export type Action = z.infer<typeof Action>
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ruleset: Ruleset): Action {
|
||||
const rule = ruleset[permission]
|
||||
if (!rule) return "ask"
|
||||
|
||||
let best: { length: number; action: Action } | undefined
|
||||
for (const [p, action] of Object.entries(rule)) {
|
||||
if (!Wildcard.match(pattern, p)) continue
|
||||
if (!best || p.length > best.length) {
|
||||
best = { length: p.length, action }
|
||||
}
|
||||
}
|
||||
|
||||
return best?.action ?? "ask"
|
||||
}
|
||||
|
||||
export class RejectedError extends Error {
|
||||
constructor(public readonly reason?: string) {
|
||||
|
|
|
|||
91
packages/opencode/test/permission/next.test.ts
Normal file
91
packages/opencode/test/permission/next.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { test, expect } from "bun:test"
|
||||
import { PermissionNext } from "../../src/permission/next"
|
||||
|
||||
// fromConfig tests
|
||||
|
||||
test("fromConfig - string value becomes wildcard", () => {
|
||||
const result = PermissionNext.fromConfig({ bash: "allow" })
|
||||
expect(result).toEqual({ bash: { "*": "allow" } })
|
||||
})
|
||||
|
||||
test("fromConfig - object value stays as-is", () => {
|
||||
const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } })
|
||||
expect(result).toEqual({ bash: { "*": "allow", rm: "deny" } })
|
||||
})
|
||||
|
||||
test("fromConfig - mixed string and object values", () => {
|
||||
const result = PermissionNext.fromConfig({
|
||||
bash: { "*": "allow", rm: "deny" },
|
||||
edit: "allow",
|
||||
webfetch: "ask",
|
||||
})
|
||||
expect(result).toEqual({
|
||||
bash: { "*": "allow", rm: "deny" },
|
||||
edit: { "*": "allow" },
|
||||
webfetch: { "*": "ask" },
|
||||
})
|
||||
})
|
||||
|
||||
test("fromConfig - empty object", () => {
|
||||
const result = PermissionNext.fromConfig({})
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
// merge tests
|
||||
|
||||
test("merge - simple override", () => {
|
||||
const result = PermissionNext.merge({ bash: { "*": "allow" } }, { bash: { "*": "deny" } })
|
||||
expect(result).toEqual({ bash: { "*": "deny" } })
|
||||
})
|
||||
|
||||
test("merge - adds new permission", () => {
|
||||
const result = PermissionNext.merge({ bash: { "*": "allow" } }, { edit: { "*": "deny" } })
|
||||
expect(result).toEqual({
|
||||
bash: { "*": "allow" },
|
||||
edit: { "*": "deny" },
|
||||
})
|
||||
})
|
||||
|
||||
test("merge - wildcard wipes specific patterns", () => {
|
||||
const result = PermissionNext.merge({ bash: { foo: "ask", bar: "allow" } }, { bash: { "*": "deny" } })
|
||||
expect(result).toEqual({ bash: { "*": "deny" } })
|
||||
})
|
||||
|
||||
test("merge - specific pattern after wildcard", () => {
|
||||
const result = PermissionNext.merge({ bash: { "*": "deny" } }, { bash: { foo: "allow" } })
|
||||
expect(result).toEqual({ bash: { "*": "deny", foo: "allow" } })
|
||||
})
|
||||
|
||||
test("merge - glob pattern wipes matching patterns", () => {
|
||||
const result = PermissionNext.merge(
|
||||
{ bash: { "foo/bar": "ask", "foo/baz": "allow", other: "deny" } },
|
||||
{ bash: { "foo/*": "deny" } },
|
||||
)
|
||||
expect(result).toEqual({ bash: { "foo/*": "deny", other: "deny" } })
|
||||
})
|
||||
|
||||
test("merge - multiple rulesets", () => {
|
||||
const result = PermissionNext.merge({ bash: { "*": "allow" } }, { bash: { rm: "ask" } }, { edit: { "*": "allow" } })
|
||||
expect(result).toEqual({
|
||||
bash: { "*": "allow", rm: "ask" },
|
||||
edit: { "*": "allow" },
|
||||
})
|
||||
})
|
||||
|
||||
test("merge - empty ruleset does nothing", () => {
|
||||
const result = PermissionNext.merge({ bash: { "*": "allow" } }, {})
|
||||
expect(result).toEqual({ bash: { "*": "allow" } })
|
||||
})
|
||||
|
||||
test("merge - nested glob patterns", () => {
|
||||
const result = PermissionNext.merge(
|
||||
{ edit: { "src/components/Button.tsx": "allow", "src/components/Input.tsx": "allow" } },
|
||||
{ edit: { "src/components/*": "deny" } },
|
||||
)
|
||||
expect(result).toEqual({ edit: { "src/components/*": "deny" } })
|
||||
})
|
||||
|
||||
test("merge - non-matching glob preserves existing", () => {
|
||||
const result = PermissionNext.merge({ edit: { "src/foo.ts": "allow" } }, { edit: { "test/*": "deny" } })
|
||||
expect(result).toEqual({ edit: { "src/foo.ts": "allow", "test/*": "deny" } })
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue