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,32 +1,64 @@
export * as ConfigAgentPlugin from "./agent"
import { Effect } from "effect"
import path from "path"
import { Effect, Option, Schema } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { ConfigAgent } from "../agent"
import { ConfigMarkdown } from "../markdown"
import { FSUtil } from "../../fs-util"
import { ModelV2 } from "../../model"
import { PermissionV2 } from "../../permission"
import { PluginV2 } from "../../plugin"
import { ConfigAgentV1 } from "../../v1/config/agent"
import { ConfigMigrateV1 } from "../../v1/config/migrate"
const legacySources = [
{ pattern: "{agent,agents}/**/*.md", primary: false },
{ pattern: "{mode,modes}/*.md", primary: true },
] as const
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
const decodeConfig = Schema.decodeUnknownOption(Config.Info)
const agentKeys = new Set(["model", "variant", "request", "system", "description", "mode", "hidden", "color", "steps", "disabled", "permissions"])
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-agent"),
effect: Effect.gen(function* () {
const agent = yield* AgentV2.Service
const config = yield* Config.Service
const files = yield* config.get()
const fs = yield* FSUtil.Service
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([entry])
return Effect.gen(function* () {
const files = yield* discover(fs, entry.path)
return yield* Effect.forEach(files, (file) =>
fs.readFileStringSafe(file.filepath).pipe(
Effect.map((content) => content && decode(file, content)),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(
Effect.map((documents) => documents.filter((document): document is Config.Document => document !== undefined)),
)
})
}).pipe(Effect.map((documents) => documents.flat()))
yield* agent.update((editor) => {
const permissions = new Map<AgentV2.ID, PermissionV2.Ruleset>()
const global = documents.flatMap((document) => document.info.permissions ?? [])
for (const current of editor.list()) {
editor.update(current.id, (agent) => agent.permissions.push(...global))
}
for (const file of files) {
for (const [id, item] of Object.entries(file.info.agents ?? {})) {
for (const document of documents) {
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
const agentID = AgentV2.ID.make(id)
if (item.disabled) {
editor.remove(agentID)
permissions.delete(agentID)
continue
}
const exists = editor.get(agentID) !== undefined
editor.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...global)
if (item.model !== undefined) {
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
@ -44,20 +76,49 @@ export const Plugin = PluginV2.define({
if (item.hidden !== undefined) agent.hidden = item.hidden
if (item.color !== undefined) agent.color = item.color
if (item.steps !== undefined) agent.steps = item.steps
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
})
if (item.permissions !== undefined) {
permissions.set(agentID, [...(permissions.get(agentID) ?? []), ...item.permissions])
}
}
}
const global = files.flatMap((file) => file.info.permissions ?? [])
for (const current of editor.list()) {
editor.update(current.id, (agent) => {
agent.permissions.push(...global, ...(permissions.get(current.id) ?? []))
})
}
})
}),
})
function discover(fs: FSUtil.Interface, directory: string) {
return Effect.forEach(legacySources, (source) =>
fs
.glob(source.pattern, { cwd: directory, absolute: true, dot: true, symlink: true })
.pipe(Effect.map((files) => files.toSorted().map((filepath) => ({ directory, filepath, primary: source.primary })))),
).pipe(
Effect.map((files) => files.flat()),
Effect.catch(() => Effect.succeed([])),
)
}
function decode(file: { directory: string; filepath: string; primary: boolean }, content: string) {
const markdown = ConfigMarkdown.parseOption(content)
if (!markdown) return
const name = path
.relative(file.directory, file.filepath)
.replaceAll("\\", "/")
.replace(/^(agent|agents|mode|modes)\//, "")
.replace(/\.md$/, "")
const body = markdown.content.trim()
const legacy = Object.keys(markdown.data).some((key) => !agentKeys.has(key))
const agent = Option.getOrUndefined(
legacy
? Option.map(
decodeLegacyAgent({ name, ...markdown.data, prompt: body }, { errors: "all", propertyOrder: "original" }),
ConfigMigrateV1.migrateAgent,
)
: decodeAgent({ ...markdown.data, system: body }, { errors: "all", propertyOrder: "original" }),
)
if (!agent) return
const info = Option.getOrUndefined(
decodeConfig({
agents: { [name]: file.primary ? { ...agent, mode: "primary" } : agent },
}),
)
if (!info) return
return new Config.Document({ type: "document", path: file.filepath, info })
}

View file

@ -13,7 +13,7 @@ export const Plugin = PluginV2.define({
const catalog = yield* Catalog.Service
const config = yield* Config.Service
const transform = yield* catalog.transform()
const files = yield* config.get()
const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
yield* transform((catalog) => {
for (const file of files) {

View file

@ -0,0 +1,39 @@
export * as ConfigSkillPlugin from "./skill"
import path from "path"
import { Effect } from "effect"
import { Config } from "../../config"
import { Global } from "../../global"
import { Location } from "../../location"
import { PluginV2 } from "../../plugin"
import { AbsolutePath } from "../../schema"
import { SkillV2 } from "../../skill"
export const Plugin = PluginV2.define({
id: PluginV2.ID.make("config-skill"),
effect: Effect.gen(function* () {
const config = yield* Config.Service
const global = yield* Global.Service
const location = yield* Location.Service
const skill = yield* SkillV2.Service
const transform = yield* skill.transform()
const entries = yield* config.entries()
const items = entries.flatMap((entry) => (entry.type === "document" ? entry.info.skills ?? [] : []))
yield* transform((editor) => {
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
editor.source(new SkillV2.UrlSource({ type: "url", url: item }))
continue
}
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
editor.source(
new SkillV2.DirectorySource({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
)
}
})
}),
})