This commit is contained in:
Dax Raad 2025-12-17 19:11:11 -05:00
parent 6adb22d290
commit 0dad18f1e5
3 changed files with 167 additions and 148 deletions

View file

@ -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
}

View file

@ -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) {

View 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" } })
})