Ver código fonte

feat(core): add configurable agent state

Dax Raad 2 meses atrás
pai
commit
8e4cbf6087

+ 104 - 0
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<Info>("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<ID, Info>
+}
+
+export type Editor = {
+  list: () => readonly Info[]
+  get: (id: ID) => Info | undefined
+  update: (id: ID, fn: (agent: Draft<Info>) => void) => void
+  remove: (id: ID) => void
+}
+
+export interface Interface {
+  readonly transform: State.Interface<Data, Editor>["transform"]
+  readonly update: State.Interface<Data, Editor>["update"]
+  readonly get: (id: ID) => Effect.Effect<Info | undefined>
+  readonly all: () => Effect.Effect<Info[]>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
+
+enableMapSet()
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const state = State.create<Data, Editor>({
+      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

+ 7 - 5
packages/core/src/config.ts

@@ -92,14 +92,16 @@ export class Info extends Schema.Class<Info>("Config.Info")({
   providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
 }) {}
 
-export class FileSource extends Schema.Class<FileSource>("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<MemorySource>("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) {

+ 66 - 0
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<AgentV2.ID, PermissionV2.Ruleset>()
+
+      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) ?? []))
+        })
+      }
+    })
+  }),
+})

+ 1 - 1
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
 

+ 7 - 1
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),
 )

+ 105 - 0
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()
+    }),
+  )
+})

+ 186 - 0
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()
+    }),
+  )
+})

+ 0 - 1
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"),
             )

+ 3 - 3
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" },

+ 23 - 0
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"),
+    })
+  })
+})

+ 1 - 1
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,