diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts new file mode 100644 index 0000000000..55d95ef77c --- /dev/null +++ b/packages/core/src/agent.ts @@ -0,0 +1,104 @@ +export * as AgentV2 from "./agent" + +import { Array, Context, Effect, Layer, Schema } from "effect" +import { castDraft, enableMapSet, type Draft } from "immer" +import { ModelV2 } from "./model" +import { PermissionV2 } from "./permission" +import { ProviderV2 } from "./provider" +import { PositiveInt } from "./schema" +import { State } from "./state" + +export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID")) +export type ID = typeof ID.Type + +export const Color = Schema.Union([ + Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), + Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), +]) + +export class Info extends Schema.Class("AgentV2.Info")({ + id: ID, + model: ModelV2.Ref.pipe(Schema.optional), + options: ProviderV2.Options, + system: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + mode: Schema.Literals(["subagent", "primary", "all"]), + hidden: Schema.Boolean, + color: Color.pipe(Schema.optional), + steps: PositiveInt.pipe(Schema.optional), + permissions: PermissionV2.Ruleset, +}) { + static empty(id: ID) { + return new Info({ + id, + options: { + headers: {}, + body: {}, + aisdk: { + provider: {}, + request: {}, + }, + }, + mode: "all", + hidden: false, + permissions: [], + }) + } +} + +type Data = { + agents: Map +} + +export type Editor = { + list: () => readonly Info[] + get: (id: ID) => Info | undefined + update: (id: ID, fn: (agent: Draft) => void) => void + remove: (id: ID) => void +} + +export interface Interface { + readonly transform: State.Interface["transform"] + readonly update: State.Interface["update"] + readonly get: (id: ID) => Effect.Effect + readonly all: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Agent") {} + +enableMapSet() + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = State.create({ + initial: () => ({ agents: new Map() }), + editor: (draft) => ({ + list: () => Array.fromIterable(draft.agents.values()) as Info[], + get: (id) => draft.agents.get(id), + update: (id, fn) => { + const current = draft.agents.get(id) ?? castDraft(Info.empty(id)) + if (!draft.agents.has(id)) draft.agents.set(id, current) + fn(current) + current.id = id + }, + remove: (id) => { + draft.agents.delete(id) + }, + }), + }) + + return Service.of({ + transform: state.transform, + update: state.update, + get: Effect.fn("AgentV2.get")(function* (id) { + return state.get().agents.get(id) + }), + all: Effect.fn("AgentV2.all")(function* () { + return Array.fromIterable(state.get().agents.values()) + }), + }) + }), +) + +export const defaultLayer = layer diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index c31348950e..88ff2ef91e 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -92,14 +92,16 @@ export class Info extends Schema.Class("Config.Info")({ providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional), }) {} -export class FileSource extends Schema.Class("Config.FileSource")({ +export const FileSource = Schema.Struct({ type: Schema.Literal("file"), path: Schema.String, -}) {} +}).annotate({ identifier: "Config.FileSource" }) +export type FileSource = typeof FileSource.Type -export class MemorySource extends Schema.Class("Config.MemorySource")({ +export const MemorySource = Schema.Struct({ type: Schema.Literal("memory"), -}) {} +}).annotate({ identifier: "Config.MemorySource" }) +export type MemorySource = typeof MemorySource.Type export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type")) export type Source = typeof Source.Type @@ -141,7 +143,7 @@ export const layer = Layer.effect( Schema.decodeUnknownOption(Info)(input, { errors: "all", onExcessProperty: "ignore" }), ) if (!info) return - return new Loaded({ source: new FileSource({ type: "file", path: filepath }), info }) + return new Loaded({ source: { type: "file", path: filepath }, info }) }) const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) { diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts new file mode 100644 index 0000000000..be2aca6297 --- /dev/null +++ b/packages/core/src/config/plugin/agent.ts @@ -0,0 +1,66 @@ +export * as ConfigAgentPlugin from "./agent" + +import { Effect } from "effect" +import { AgentV2 } from "../../agent" +import { Config } from "../../config" +import { ModelV2 } from "../../model" +import { PermissionV2 } from "../../permission" +import { PluginV2 } from "../../plugin" + +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 transform = yield* agent.transform() + const files = yield* config.get() + + yield* transform((editor) => { + const permissions = new Map() + + for (const file of files) { + for (const [id, item] of Object.entries(file.info.agents ?? {})) { + const agentID = AgentV2.ID.make(id) + if (item.disabled) { + editor.remove(agentID) + permissions.delete(agentID) + continue + } + + editor.update(agentID, (agent) => { + if (item.model !== undefined) { + const model = ModelV2.parse(item.model) + agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant } + } + if (item.variant !== undefined && agent.model !== undefined) { + agent.model.variant = ModelV2.VariantID.make(item.variant) + } + if (item.options !== undefined) { + Object.assign(agent.options.headers, item.options.headers ?? {}) + Object.assign(agent.options.body, item.options.body ?? {}) + Object.assign(agent.options.aisdk.provider, item.options.aisdk?.provider ?? {}) + Object.assign(agent.options.aisdk.request, item.options.aisdk?.request ?? {}) + } + if (item.system !== undefined) agent.system = item.system + if (item.description !== undefined) agent.description = item.description + if (item.mode !== undefined) agent.mode = item.mode + 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) { + 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) ?? [])) + }) + } + }) + }), +}) diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 77b8c60ebe..8cf02ddfe0 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -36,7 +36,7 @@ export const Cost = Schema.Struct({ export const Ref = Schema.Struct({ id: ID, providerID: ProviderV2.ID, - variant: VariantID, + variant: VariantID.pipe(Schema.optional), }) export type Ref = typeof Ref.Type diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index aac7011be4..39faa62f8d 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -2,8 +2,10 @@ export * as PluginBoot from "./boot" import { Context, Deferred, Effect, Layer } from "effect" import { AccountV2 } from "../account" +import { AgentV2 } from "../agent" import { Catalog } from "../catalog" import { Config } from "../config" +import { ConfigAgentPlugin } from "../config/plugin/agent" import { EventV2 } from "../event" import { Npm } from "../npm" import { PluginV2 } from "../plugin" @@ -16,7 +18,7 @@ import { ProviderPlugins } from "./provider" type Plugin = { id: PluginV2.ID effect: PluginV2.Effect< - Catalog.Service | AccountV2.Service | Npm.Service | EventV2.Service | PluginV2.Service | Config.Service + Catalog.Service | AccountV2.Service | AgentV2.Service | Npm.Service | EventV2.Service | PluginV2.Service | Config.Service > } @@ -32,6 +34,7 @@ export const layer = Layer.effect( const catalog = yield* Catalog.Service const plugin = yield* PluginV2.Service const accounts = yield* AccountV2.Service + const agents = yield* AgentV2.Service const config = yield* Config.Service const npm = yield* Npm.Service const events = yield* EventV2.Service @@ -43,6 +46,7 @@ export const layer = Layer.effect( effect: input.effect.pipe( Effect.provideService(Catalog.Service, catalog), Effect.provideService(AccountV2.Service, accounts), + Effect.provideService(AgentV2.Service, agents), Effect.provideService(Config.Service, config), Effect.provideService(Npm.Service, npm), Effect.provideService(EventV2.Service, events), @@ -59,6 +63,7 @@ export const layer = Layer.effect( } yield* add(ModelsDevPlugin) yield* add(ConfigProviderPlugin.Plugin) + yield* add(ConfigAgentPlugin.Plugin) }).pipe(Effect.withSpan("PluginBoot.boot")) yield* boot.pipe( @@ -78,6 +83,7 @@ export const defaultLayer = layer.pipe( Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer), Layer.provide(AccountV2.defaultLayer), + Layer.provide(AgentV2.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(Npm.defaultLayer), ) diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts new file mode 100644 index 0000000000..2449a91a7c --- /dev/null +++ b/packages/core/test/agent.test.ts @@ -0,0 +1,105 @@ +import { describe, expect } from "bun:test" +import { Effect, Exit, Scope } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { testEffect } from "./lib/effect" + +const it = testEffect(AgentV2.defaultLayer) + +describe("AgentV2", () => { + it.effect("starts without agents", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + + expect(yield* agent.all()).toEqual([]) + expect(yield* agent.get(AgentV2.ID.make("build"))).toBeUndefined() + }), + ) + + it.effect("materializes replayable agent transforms", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const id = AgentV2.ID.make("reviewer") + const transform = yield* agent.transform() + + yield* transform((editor) => + editor.update(id, (info) => { + info.description = "Reviews code" + info.mode = "subagent" + }), + ) + + expect(yield* agent.get(id)).toMatchObject({ id, description: "Reviews code", mode: "subagent" }) + expect((yield* agent.all()).map((info) => info.id)).toEqual([id]) + }), + ) + + it.effect("rebuilds state when a transform is replaced", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const id = AgentV2.ID.make("reviewer") + const transform = yield* agent.transform() + + yield* transform((editor) => + editor.update(id, (info) => { + info.description = "Old description" + info.hidden = true + }), + ) + yield* transform((editor) => + editor.update(id, (info) => { + info.description = "New description" + }), + ) + + expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false }) + }), + ) + + it.effect("removes a transform contribution when its scope closes", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const id = AgentV2.ID.make("scoped") + const scope = yield* Scope.make() + const transform = yield* agent.transform().pipe(Scope.provide(scope)) + + yield* transform((editor) => editor.update(id, () => {})) + expect(yield* agent.get(id)).toBeDefined() + + yield* Scope.close(scope, Exit.void) + expect(yield* agent.get(id)).toBeUndefined() + }), + ) + + it.effect("applies direct agent updates", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const id = AgentV2.ID.make("build") + + yield* agent.update((editor) => + Effect.sync(() => + editor.update(id, (info) => { + info.mode = "primary" + info.hidden = true + }), + ), + ) + + expect(yield* agent.get(id)).toMatchObject({ id, mode: "primary", hidden: true }) + }), + ) + + it.effect("creates agents with runtime defaults and supports direct removal", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const id = AgentV2.ID.make("custom") + + yield* agent.update((editor) => Effect.sync(() => editor.update(id, () => {}))) + expect(yield* agent.get(id)).toEqual( + AgentV2.Info.empty(id), + ) + + yield* agent.update((editor) => Effect.sync(() => editor.remove(id))) + expect(yield* agent.get(id)).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts new file mode 100644 index 0000000000..0d8562313b --- /dev/null +++ b/packages/core/test/config/agent.test.ts @@ -0,0 +1,186 @@ +import { describe, expect } from "bun:test" +import { Effect, 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 { PermissionV2 } from "@opencode-ai/core/permission" +import { testEffect } from "../lib/effect" + +const it = testEffect(AgentV2.defaultLayer) +const decode = Schema.decodeUnknownSync(Config.Info) + +describe("ConfigAgentPlugin.Plugin", () => { + it.effect("applies global permissions between built-in and agent-specific permissions", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const build = AgentV2.ID.make("build") + const defaults = yield* agents.transform() + + yield* defaults((editor) => + editor.update(build, (agent) => { + agent.mode = "primary" + agent.permissions.push({ permission: "bash", pattern: "*", action: "allow" }) + }), + ) + + const config = Config.Service.of({ + directories: () => Effect.succeed([]), + get: () => + Effect.succeed([ + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + permissions: [{ permission: "bash", pattern: "*", action: "ask" }], + agents: { + build: { + permissions: [{ permission: "bash", pattern: "git *", action: "allow" }], + }, + reviewer: { + model: "openrouter/openai/gpt-5", + description: "Review changes", + mode: "subagent", + permissions: [{ permission: "edit", pattern: "*", action: "deny" }], + }, + removed: { description: "Removed later" }, + }, + }), + }), + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + agents: { + reviewer: { variant: "high", hidden: true }, + removed: { disabled: true }, + }, + }), + }), + ]), + }) + + yield* ConfigAgentPlugin.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(AgentV2.Service, agents), + ) + + const buildAgent = yield* agents.get(build) + if (!buildAgent) throw new Error("expected configured build agent") + expect(buildAgent.permissions).toEqual([ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "git *", action: "allow" }, + ]) + expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).action).toBe("allow") + expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).action).toBe("ask") + + const reviewer = yield* agents.get(AgentV2.ID.make("reviewer")) + if (!reviewer) throw new Error("expected configured reviewer agent") + expect(reviewer).toMatchObject({ + description: "Review changes", + mode: "subagent", + hidden: true, + model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" }, + }) + expect(reviewer.permissions).toEqual([ + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "*", action: "deny" }, + ]) + expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined() + }), + ) + + it.effect("maps configured agent fields and preserves an unspecified model variant", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const config = Config.Service.of({ + directories: () => Effect.succeed([]), + get: () => + Effect.succeed([ + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + agents: { + reviewer: { + model: "anthropic/claude-sonnet", + system: "Review carefully.", + description: "Reviews changes", + mode: "subagent", + hidden: true, + color: "warning", + steps: 12, + options: { + headers: { first: "one", shared: "first" }, + body: { enabled: true }, + aisdk: { provider: { profile: "review" }, request: { effort: "medium" } }, + }, + }, + }, + }), + }), + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ + agents: { + reviewer: { + options: { + headers: { shared: "last", second: "two" }, + body: { retries: 2 }, + aisdk: { request: { effort: "high" } }, + }, + }, + }, + }), + }), + ]), + }) + + yield* ConfigAgentPlugin.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(AgentV2.Service, agents), + ) + + const reviewer = yield* agents.get(AgentV2.ID.make("reviewer")) + if (!reviewer) throw new Error("expected configured reviewer agent") + expect(reviewer).toMatchObject({ + system: "Review carefully.", + description: "Reviews changes", + mode: "subagent", + hidden: true, + color: "warning", + steps: 12, + model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined }, + }) + expect(reviewer.options).toEqual({ + headers: { first: "one", shared: "last", second: "two" }, + body: { enabled: true, retries: 2 }, + aisdk: { provider: { profile: "review" }, request: { effort: "high" } }, + }) + }), + ) + + it.effect("removes a built-in agent disabled by configuration", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const build = AgentV2.ID.make("build") + const defaults = yield* agents.transform() + yield* defaults((editor) => editor.update(build, () => {})) + + const config = Config.Service.of({ + directories: () => Effect.succeed([]), + get: () => + Effect.succeed([ + new Config.Loaded({ + source: { type: "memory" }, + info: decode({ agents: { build: { disabled: true } } }), + }), + ]), + }) + + yield* ConfigAgentPlugin.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(AgentV2.Service, agents), + ) + + expect(yield* agents.get(build)).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 53c4eb22ad..0a39ed4b3e 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -105,7 +105,6 @@ describe("Config", () => { expect(documents.map((document) => document.source.type)).toEqual(["file", "file", "file"]) expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"]) expect(documents[0]).toBeInstanceOf(Config.Loaded) - expect(documents[0]?.source).toBeInstanceOf(Config.FileSource) expect(documents[0]?.source.type === "file" ? documents[0].source.path : undefined).toBe( path.join(tmp.path, "config.json"), ) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 5fa4874701..01b8c9c9aa 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -29,7 +29,7 @@ describe("ConfigProviderPlugin.Plugin", () => { get: () => Effect.succeed([ new Config.Loaded({ - source: new Config.MemorySource({ type: "memory" }), + source: { type: "memory" }, info: decode({ providers: { custom: { @@ -58,7 +58,7 @@ describe("ConfigProviderPlugin.Plugin", () => { }), }), new Config.Loaded({ - source: new Config.MemorySource({ type: "memory" }), + source: { type: "memory" }, info: decode({ providers: { custom: { @@ -87,7 +87,7 @@ describe("ConfigProviderPlugin.Plugin", () => { }), }), new Config.Loaded({ - source: new Config.MemorySource({ type: "memory" }), + source: { type: "memory" }, info: decode({ providers: { custom: { name: "Renamed" }, diff --git a/packages/core/test/model.test.ts b/packages/core/test/model.test.ts new file mode 100644 index 0000000000..fe97acc25a --- /dev/null +++ b/packages/core/test/model.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" + +const decode = Schema.decodeUnknownSync(ModelV2.Ref) + +describe("ModelV2.Ref", () => { + test("accepts a model selection without a variant", () => { + expect(decode({ id: "claude-sonnet", providerID: "anthropic" })).toEqual({ + id: ModelV2.ID.make("claude-sonnet"), + providerID: ProviderV2.ID.make("anthropic"), + }) + }) + + test("preserves an explicit model variant", () => { + expect(decode({ id: "claude-sonnet", providerID: "anthropic", variant: "high" })).toEqual({ + id: ModelV2.ID.make("claude-sonnet"), + providerID: ProviderV2.ID.make("anthropic"), + variant: ModelV2.VariantID.make("high"), + }) + }) +}) diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 5e477cc8a3..551f030ffc 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -166,7 +166,7 @@ export const layer = Layer.effect( ? { id: ModelV2.ID.make(row.model.id), providerID: ProviderV2.ID.make(row.model.providerID), - variant: ModelV2.VariantID.make(row.model.variant ?? "default"), + variant: row.model.variant ? ModelV2.VariantID.make(row.model.variant) : undefined, } : undefined, cost: row.cost,