feat(core): add skill registry and file agent loading (#30617)

This commit is contained in:
Dax 2026-06-03 16:58:34 -04:00 committed by GitHub
commit 889e0f9545
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 881 additions and 189 deletions

View file

@ -1,16 +1,21 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer, Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
const it = testEffect(AgentV2.locationLayer)
const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer))
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigAgentPlugin.Plugin", () => {
it.effect("applies global permissions between built-in and agent-specific permissions", () =>
it.effect("applies all global permissions before agent-specific permissions", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const build = AgentV2.ID.make("build")
@ -24,11 +29,10 @@ describe("ConfigAgentPlugin.Plugin", () => {
)
const config = Config.Service.of({
directories: () => Effect.succeed([]),
get: () =>
entries: () =>
Effect.succeed([
new Config.Loaded({
source: { type: "memory" },
new Config.Document({
type: "document",
info: decode({
permissions: [{ action: "bash", resource: "*", effect: "ask" }],
agents: {
@ -39,18 +43,25 @@ describe("ConfigAgentPlugin.Plugin", () => {
model: "openrouter/openai/gpt-5",
description: "Review changes",
mode: "subagent",
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
permissions: [
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "deny" },
],
},
removed: { description: "Removed later" },
},
}),
}),
new Config.Loaded({
source: { type: "memory" },
new Config.Document({
type: "document",
info: decode({
permissions: [{ action: "read", resource: "*", effect: "allow" }],
agents: {
reviewer: { variant: "high", hidden: true },
removed: { disabled: true },
late: {
permissions: [{ action: "edit", resource: "*", effect: "allow" }],
},
},
}),
}),
@ -67,6 +78,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
expect(buildAgent.permissions).toEqual([
{ action: "bash", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "bash", resource: "git *", effect: "allow" },
])
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
@ -82,7 +94,15 @@ describe("ConfigAgentPlugin.Plugin", () => {
})
expect(reviewer.permissions).toEqual([
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "deny" },
])
expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "allow" },
])
expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
}),
@ -92,11 +112,10 @@ describe("ConfigAgentPlugin.Plugin", () => {
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const config = Config.Service.of({
directories: () => Effect.succeed([]),
get: () =>
entries: () =>
Effect.succeed([
new Config.Loaded({
source: { type: "memory" },
new Config.Document({
type: "document",
info: decode({
agents: {
reviewer: {
@ -115,8 +134,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
},
}),
}),
new Config.Loaded({
source: { type: "memory" },
new Config.Document({
type: "document",
info: decode({
agents: {
reviewer: {
@ -162,11 +181,10 @@ describe("ConfigAgentPlugin.Plugin", () => {
yield* defaults((editor) => editor.update(build, () => {}))
const config = Config.Service.of({
directories: () => Effect.succeed([]),
get: () =>
entries: () =>
Effect.succeed([
new Config.Loaded({
source: { type: "memory" },
new Config.Document({
type: "document",
info: decode({ agents: { build: { disabled: true } } }),
}),
]),
@ -180,4 +198,81 @@ describe("ConfigAgentPlugin.Plugin", () => {
expect(yield* agents.get(build)).toBeUndefined()
}),
)
it.live("loads legacy file-based agents from config directories", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "agents", "team"), { recursive: true })
await fs.mkdir(path.join(tmp.path, "modes"), { recursive: true })
await fs.writeFile(
path.join(tmp.path, "agents", "reviewer.md"),
`---
model: openrouter/openai/gpt-5
description: Markdown description
temperature: 0.5
tools:
write: false
---
Review carefully.`,
)
await fs.writeFile(path.join(tmp.path, "agents", "team", "helper.md"), "Help the team.")
await fs.writeFile(
path.join(tmp.path, "agents", "native.md"),
`---
request:
headers:
x-agent: native
body:
effort: high
permissions:
- action: edit
resource: "*"
effect: deny
---
Use native v2 fields.`,
)
await fs.writeFile(path.join(tmp.path, "agents", "disabled.md"), "---\ndisabled: true\n---\nDisabled")
await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.")
})
const agents = yield* AgentV2.Service
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({ agents: { reviewer: { description: "JSON description" } } }),
}),
new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
]),
})
yield* ConfigAgentPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(AgentV2.Service, agents),
)
expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({
model: { providerID: "openrouter", id: "openai/gpt-5" },
system: "Review carefully.",
description: "Markdown description",
request: { body: { temperature: 0.5 } },
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
})
expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({
system: "Use native v2 fields.",
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
})
expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined()
expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
}),
),
),
)
})

View file

@ -108,9 +108,9 @@ describe("Config", () => {
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
const entries = yield* config.entries()
expect(documents).toEqual([])
expect(entries).toEqual([new Config.Directory({ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) })])
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
@ -145,21 +145,23 @@ describe("Config", () => {
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
expect(documents).toHaveLength(3)
expect(documents.map((document) => document.source.type)).toEqual(["file", "file", "file"])
expect(documents.map((document) => document.type)).toEqual(["document", "document", "document"])
expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
expect(documents[0]).toBeInstanceOf(Config.Loaded)
expect(documents[0]?.source.type === "file" ? documents[0].source.path : undefined).toBe(
path.join(tmp.path, "config.json"),
)
expect(documents[0]).toBeInstanceOf(Config.Document)
expect(documents[0]?.path).toBe(path.join(tmp.path, "config.json"))
expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
yield* Effect.promise(() =>
fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
)
expect((yield* config.get()).map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
expect((yield* config.entries()).filter((entry) => entry.type === "document").map((document) => document.info.$schema)).toEqual([
"base",
"middle",
"last",
])
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
),
@ -183,7 +185,7 @@ describe("Config", () => {
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
expect(documents[0]?.info.$schema).toBeUndefined()
expect(documents[0]?.info.shell).toBe("/bin/zsh")
@ -291,7 +293,7 @@ describe("Config", () => {
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
expect(documents).toHaveLength(1)
expect(documents[0]?.info.shell).toBe("/bin/bash")
@ -450,7 +452,7 @@ describe("Config", () => {
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
expect(documents).toHaveLength(1)
expect(documents[0]?.info).toBeInstanceOf(Config.Info)
@ -528,7 +530,7 @@ describe("Config", () => {
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = yield* config.get()
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
}).pipe(Effect.provide(testLayer(tmp.path)))
@ -603,10 +605,10 @@ describe("Config", () => {
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const directories = yield* config.directories()
const documents = yield* config.get()
const entries = yield* config.entries()
const documents = entries.filter((entry) => entry.type === "document")
expect(directories).toEqual([
expect(entries.filter((entry) => entry.type === "directory").map((entry) => entry.path)).toEqual([
AbsolutePath.make(global),
AbsolutePath.make(path.join(root, ".opencode")),
AbsolutePath.make(path.join(directory, ".opencode")),
@ -619,6 +621,17 @@ describe("Config", () => {
"root-dot",
"directory-dot",
])
expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
"global",
AbsolutePath.make(global),
"root",
"parent",
"directory",
"root-dot",
AbsolutePath.make(path.join(root, ".opencode")),
"directory-dot",
AbsolutePath.make(path.join(directory, ".opencode")),
])
}).pipe(
Effect.provide(
testLayer(directory, global, root, {

View file

@ -25,11 +25,10 @@ describe("ConfigProviderPlugin.Plugin", () => {
const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat")
const config = Config.Service.of({
directories: () => Effect.succeed([]),
get: () =>
entries: () =>
Effect.succeed([
new Config.Loaded({
source: { type: "memory" },
new Config.Document({
type: "document",
info: decode({
providers: {
custom: {
@ -57,8 +56,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
},
}),
}),
new Config.Loaded({
source: { type: "memory" },
new Config.Document({
type: "document",
info: decode({
providers: {
custom: {
@ -86,8 +85,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
},
}),
}),
new Config.Loaded({
source: { type: "memory" },
new Config.Document({
type: "document",
info: decode({
providers: {
custom: { name: "Renamed" },

View file

@ -0,0 +1,67 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SkillV2 } from "@opencode-ai/core/skill"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.empty)
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigSkillPlugin.Plugin", () => {
it.effect("registers configured skill directories and URLs", () =>
Effect.gen(function* () {
const directory = AbsolutePath.make("/repo/packages/app")
const sources: SkillV2.Source[] = []
const transform = Effect.fnUntraced(function* () {
return Effect.fnUntraced(function* (update: (editor: SkillV2.Editor) => void) {
update({
source: (source) => sources.push(source),
list: () => sources,
})
})
})
yield* ConfigSkillPlugin.Plugin.effect.pipe(
Effect.provideService(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
skills: ["./skills", "~/shared-skills", "/opt/skills", "https://example.test/skills/"],
}),
}),
]),
}),
),
Effect.provideService(Global.Service, Global.Service.of(Global.make({ home: "/home/test" }))),
Effect.provideService(Location.Service, Location.Service.of(location({ directory }))),
Effect.provideService(
SkillV2.Service,
SkillV2.Service.of({
transform,
sources: () => Effect.succeed(sources),
list: () => Effect.succeed([]),
forAgent: () => Effect.succeed([]),
}),
),
)
expect(sources).toEqual([
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join("/home/test", "shared-skills")) }),
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make("/opt/skills") }),
new SkillV2.UrlSource({ type: "url", url: "https://example.test/skills/" }),
])
}),
)
})

View file

@ -23,8 +23,7 @@ const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))
const configLayer = Layer.succeed(
Config.Service,
Config.Service.of({
directories: () => Effect.succeed([]),
get: () => Effect.succeed([]),
entries: () => Effect.succeed([]),
}),
)

View file

@ -214,7 +214,7 @@ describe("ProjectReference", () => {
})
function document(references: ConfigReference.Info) {
return new Config.Loaded({ source: { type: "memory" }, info: Schema.decodeUnknownSync(Config.Info)({ references }) })
return new Config.Document({ type: "document", info: Schema.decodeUnknownSync(Config.Info)({ references }) })
}
function result(
@ -237,7 +237,7 @@ function testLayer(input: {
directory: string
project: string
repos: string
documents: Config.Loaded[]
documents: Config.Document[]
ensure: RepositoryCache.Interface["ensure"]
}) {
return ProjectReference.layer.pipe(
@ -256,7 +256,7 @@ function testLayer(input: {
),
Layer.succeed(
Config.Service,
Config.Service.of({ directories: () => Effect.succeed([]), get: () => Effect.succeed(input.documents) }),
Config.Service.of({ entries: () => Effect.succeed(input.documents) }),
),
Layer.succeed(RepositoryCache.Service, RepositoryCache.Service.of({ ensure: input.ensure })),
),

View file

@ -0,0 +1,130 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SkillV2 } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const urls = new Map<string, AbsolutePath[]>()
let pulls = 0
const discovery = Layer.succeed(
SkillDiscovery.Service,
SkillDiscovery.Service.of({
pull: (url) => {
pulls++
return Effect.succeed(urls.get(url) ?? [])
},
}),
)
const it = testEffect(
SkillV2.layer.pipe(
Layer.provide(discovery),
Layer.provide(FSUtil.defaultLayer),
Layer.provideMerge(AgentV2.locationLayer),
),
)
function write(directory: string, name: string, description: string) {
return fs.writeFile(
path.join(directory, name, "SKILL.md"),
`---
name: ${name}
description: ${description}
---
# ${name}`,
)
}
describe("SkillV2", () => {
it.live("registers sources and resolves later source precedence", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(first, "review"), { recursive: true })
await fs.mkdir(path.join(second, "review"), { recursive: true })
await write(first, "review", "First")
await write(second, "review", "Second")
await fs.writeFile(path.join(first, "foo.md"), "---\nslash: true\n---\n# foo")
})
const skill = yield* SkillV2.Service
const register = yield* skill.transform()
yield* register((editor) => {
editor.source({ type: "directory", path: AbsolutePath.make(first) })
editor.source({ type: "directory", path: AbsolutePath.make(first) })
editor.source({ type: "directory", path: AbsolutePath.make(second) })
expect(editor.list()).toEqual([
{ type: "directory", path: AbsolutePath.make(first) },
{ type: "directory", path: AbsolutePath.make(second) },
])
})
expect(yield* skill.sources()).toEqual([
{ type: "directory", path: AbsolutePath.make(first) },
{ type: "directory", path: AbsolutePath.make(second) },
])
expect(yield* skill.list()).toEqual([
new SkillV2.Info({
name: "foo",
slash: true,
location: AbsolutePath.make(path.join(first, "foo.md")),
content: "# foo",
}),
{
name: "review",
description: "Second",
location: AbsolutePath.make(path.join(second, "review", "SKILL.md")),
content: "# review",
},
])
}),
),
),
)
it.live("loads URL sources and filters skills for agents", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
await write(tmp.path, "deploy", "Deploy production")
})
pulls = 0
urls.set("https://example.test/skills/", [AbsolutePath.make(tmp.path)])
const agents = yield* AgentV2.Service
yield* agents.update((editor) =>
editor.update(AgentV2.ID.make("reviewer"), (agent) => {
agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" })
}),
)
const skill = yield* SkillV2.Service
const register = yield* skill.transform()
yield* register((editor) => editor.source({ type: "url", url: "https://example.test/skills/" }))
expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"])
expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"])
expect(pulls).toBe(1)
expect(yield* skill.forAgent(AgentV2.ID.make("reviewer"))).toEqual([])
expect(yield* skill.forAgent(AgentV2.ID.make("missing"))).toEqual([])
}),
),
),
)
})