From d9b4f7b84d22c448a42628ac95ca13caf8d93c55 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 28 May 2026 01:43:23 -0400 Subject: [PATCH] feat(core): add agents and permissions config schema --- packages/core/src/config.ts | 8 +++ packages/core/src/config/agent.ts | 25 ++++++++ packages/core/src/config/provider.ts | 4 +- packages/core/test/config/config.test.ts | 44 +++++++++++++++ packages/core/test/config/provider.test.ts | 2 +- specs/v2/config.md | 66 ++++++++++++++++++++-- 6 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/config/agent.ts diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 4631b89758..d8c0e93009 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -6,8 +6,10 @@ import { Context, Effect, Layer, Option, Schema } from "effect" import { AppFileSystem } from "./filesystem" import { Global } from "./global" import { Location } from "./location" +import { PermissionV2 } from "./permission" import { Policy } from "./policy" import { AbsolutePath } from "./schema" +import { ConfigAgent } from "./config/agent" import { ConfigAttachments } from "./config/attachments" import { ConfigExperimental } from "./config/experimental" import { ConfigFormatter } from "./config/formatter" @@ -42,6 +44,12 @@ export class Info extends Schema.Class("Config.Info")({ username: Schema.String.pipe(Schema.optional).annotate({ description: "Username displayed in conversations and used for telemetry identity", }), + permissions: PermissionV2.Ruleset.pipe(Schema.optional).annotate({ + description: "Ordered tool permission rules applied to agent tool use", + }), + agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({ + description: "Named built-in agent overrides and custom agent definitions", + }), snapshots: Schema.Boolean.pipe(Schema.optional).annotate({ description: "Enable snapshots used for undo and revert behavior", }), diff --git a/packages/core/src/config/agent.ts b/packages/core/src/config/agent.ts new file mode 100644 index 0000000000..0606ac36c6 --- /dev/null +++ b/packages/core/src/config/agent.ts @@ -0,0 +1,25 @@ +export * as ConfigAgent from "./agent" + +import { Schema } from "effect" +import { PermissionV2 } from "../permission" +import { ProviderV2 } from "../provider" +import { PositiveInt } from "../schema" + +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("ConfigV2.Agent")({ + model: Schema.String.pipe(Schema.optional), + variant: Schema.String.pipe(Schema.optional), + options: ProviderV2.Options.pipe(Schema.optional), + system: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional), + hidden: Schema.Boolean.pipe(Schema.optional), + color: Color.pipe(Schema.optional), + steps: PositiveInt.pipe(Schema.optional), + disabled: Schema.Boolean.pipe(Schema.optional), + permissions: PermissionV2.Ruleset.pipe(Schema.optional), +}) {} diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index d4c6d0848e..4e0c85799f 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -22,7 +22,7 @@ class Model extends Schema.Class("ConfigV2.Model")({ ...ProviderV2.Options.fields, }).pipe(Schema.Array, Schema.optional), cost: ModelV2.Cost.pipe(Schema.Array).pipe(Schema.optional), - enabled: Schema.Boolean.pipe(Schema.optional), + disabled: Schema.Boolean.pipe(Schema.optional), limit: Schema.Struct({ context: Schema.Int, input: Schema.Int.pipe(Schema.optional), @@ -110,7 +110,7 @@ export const Plugin = PluginV2.define({ cache: { ...cost.cache }, })) } - if (config.enabled !== undefined) model.enabled = config.enabled + if (config.disabled !== undefined) model.enabled = !config.disabled if (config.limit !== undefined) model.limit = { ...config.limit } }) } diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index aac60acb1e..4dc86bd73e 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -171,6 +171,29 @@ describe("Config", () => { share: "disabled", enterprise: { url: "https://share.example.com" }, username: "test-user", + permissions: [ + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "git status", action: "allow" }, + ], + agents: { + reviewer: { + model: "openrouter/openai/gpt-5", + variant: "high", + options: { + headers: { "x-agent": "reviewer" }, + body: {}, + aisdk: { provider: {}, request: { reasoningEffort: "high" } }, + }, + description: "Review changes for correctness", + system: "Find regressions.", + mode: "subagent", + hidden: false, + color: "warning", + steps: 12, + disabled: false, + permissions: [{ permission: "edit", pattern: "*", action: "deny" }], + }, + }, snapshots: false, watcher: { ignore: ["node_modules/**", "dist/**", ".git"] }, formatter: { prettier: { disabled: true }, custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] } }, @@ -203,6 +226,27 @@ describe("Config", () => { expect(documents[0]?.info.share).toBe("disabled") expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" }) expect(documents[0]?.info.username).toBe("test-user") + expect(documents[0]?.info.permissions).toEqual([ + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "git status", action: "allow" }, + ]) + expect(documents[0]?.info.agents?.reviewer).toEqual({ + model: "openrouter/openai/gpt-5", + variant: "high", + options: { + headers: { "x-agent": "reviewer" }, + body: {}, + aisdk: { provider: {}, request: { reasoningEffort: "high" } }, + }, + description: "Review changes for correctness", + system: "Find regressions.", + mode: "subagent", + hidden: false, + color: "warning", + steps: 12, + disabled: false, + permissions: [{ permission: "edit", pattern: "*", action: "deny" }], + }) expect(documents[0]?.info.snapshots).toBe(false) expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] }) expect(documents[0]?.info.formatter).toEqual({ diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index ed1f18b7d7..b95261e050 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -45,7 +45,7 @@ describe("ConfigProvider.Plugin", () => { chat: { name: "First", capabilities: { tools: true, input: ["text"], output: ["text"] }, - enabled: false, + disabled: true, limit: { context: 100, output: 50 }, options: options({ first: "first", shared: "first" }, "retained"), variants: [ diff --git a/specs/v2/config.md b/specs/v2/config.md index 32a6f996be..49d5987b1d 100644 --- a/specs/v2/config.md +++ b/specs/v2/config.md @@ -208,11 +208,67 @@ Agent behavior and tool-access policy. Review together because agent configurati | Field | Current Purpose | Status | Notes | | --------------- | --------------------------------------------------- | ------- | ------------------------------------------- | -| `default_agent` | Choose default primary agent | pending | | -| `mode` | Legacy agent configuration alias | pending | Deprecated in favor of `agent`. | -| `agent` | Configure primary, subagent, and specialized agents | pending | | -| `permission` | Tool permission rules | pending | | -| `tools` | Legacy tool enable/disable map | pending | Converted to permissions by current loader. | +| `default_agent` | Choose default primary agent | remove | Do not retain a separate top-level selector; default choice should be designed with the v2 agent configuration model. | +| `mode` | Legacy agent configuration alias | remove | Do not port deprecated alias; configure agents through the v2 agent surface only. | +| `agent` | Configure primary, subagent, and specialized agents | redesign | Rename to plural `agents`; retain a named map of built-in overrides and custom agent definitions. | +| `permission` | Tool permission rules | redesign | Rename to plural `permissions`; replace legacy map shorthand with an ordered array of `{ permission, pattern, action }` rules. | +| `tools` | Legacy tool enable/disable map | remove | Do not port boolean enable/disable alias; express tool access through permissions. | + +Do not port `default_agent` ahead of the v2 agent design. The legacy runtime uses it to choose a visible, non-subagent fallback instead of `build`, but exposing that selection as an isolated top-level field would pre-commit v2 to the legacy agent model before agents and their policy surface are defined together. + +Do not port `mode`. The legacy loader already merges this deprecated alias into `agent`, and v2 should expose only one authoring surface for agent definitions. + +Rename legacy `agent` to `agents` because the setting is a collection keyed by agent name. It should continue to support overriding built-in agents such as `build`, `plan`, and `title`, as well as declaring named custom agents. The nested entry schema remains open until agent-local `permission` and deprecated `tools` behavior are decided. + +Keep nested `agents..mode` with values `"primary"`, `"subagent"`, or `"all"`. This identifies an agent's runtime role and is separate from the removed top-level legacy `mode` alias, which was an alternate container for agent definitions. + +For named configurable entries across v2, use `disabled?: boolean` consistently when an entry should remain configured but inactive. Agent definitions should therefore redesign legacy `disable` as `disabled`; this matches formatters, language servers, future MCP server definitions, and configured model overrides. Runtime catalog state may still track active availability as `enabled`; that is not user-authored config. + +Keep separate `model` and `variant` fields on agent definitions. A model reference uses `provider/model-id`, but model IDs may themselves contain slash-delimited segments, such as `openrouter/openai/gpt-5`; appending a variant to that string would be ambiguous. + +Keep `color` on agent definitions. Agents are user-visible selectable entities, so a user-authored display color is appropriate metadata for the agent rather than an unrelated application presentation setting. Retain hex colors and named theme colors supported by the existing configuration. + +Keep agent-local `options` provisionally using the same structured provider options shape available on configured providers and models: headers, body, and AI SDK provider/request overrides. Its long-term ownership remains open for team review because reusable provider-specific presets can instead be modeled as variants. Do not retain dedicated agent `temperature` or `top_p` fields. + +Retain `description`, `hidden`, and `steps`; they define an agent's discoverability, visibility, and iteration budget rather than model request parameters. Rename legacy agent `prompt` to `system`, making clear that it supplies persistent system-level agent content without colliding with top-level ambient `instructions`. Remove deprecated `maxSteps` in favor of `steps`. + +```jsonc +{ + "agents": { + "reviewer": { + "model": "openrouter/openai/gpt-5", + "variant": "high", + "options": { + "headers": { "x-agent": "reviewer" }, + "body": {}, + "aisdk": { "provider": {}, "request": { "reasoningEffort": "high" } }, + }, + "description": "Review changes for correctness", + "system": "Find regressions and missing tests.", + "mode": "subagent", + "color": "warning", + "steps": 12, + "disabled": false, + "permissions": [ + { "permission": "edit", "pattern": "*", "action": "deny" }, + ], + }, + }, +} +``` + +Do not port `tools`, either as a top-level setting or as an agent-entry alias. The legacy loader already converts tool booleans into permission rules, including collapsing write-adjacent tool names into `edit`; v2 should avoid carrying that lossy compatibility input forward. + +Rename legacy `permission` to `permissions` and expose the normalized ordered ruleset already modeled by `PermissionV2.Ruleset`. Rules retain the interactive `"ask"` action in addition to `"allow"` and `"deny"`; this is distinct from `experimental.policies`, whose provider enforcement currently needs only allow/deny decisions. The same `permissions` ruleset shape should be used inside future `agents` entries. + +```jsonc +{ + "permissions": [ + { "permission": "bash", "pattern": "*", "action": "ask" }, + { "permission": "bash", "pattern": "git status", "action": "allow" }, + ], +} +``` ## Group 9: Integrations