Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 98 additions & 81 deletions packages/opencode/src/config/agent.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
export * as ConfigAgent from "./agent"

import { Log } from "../util"
import { Schema } from "effect"
import z from "zod"
import { Bus } from "@/bus"
import { zod, ZodOverride } from "@/util/effect-zod"
import { Log } from "../util"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Bus } from "@/bus"
import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
Expand All @@ -13,89 +15,104 @@ import { ConfigPermission } from "./permission"

const log = Log.create({ service: "config" })

export const Info = z
.object({
model: ConfigModelID.zod.optional(),
variant: z
.string()
.optional()
.describe("Default model variant for this agent (applies only when using the agent's configured model)."),
temperature: z.number().optional(),
top_p: z.number().optional(),
prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"),
disable: z.boolean().optional(),
description: z.string().optional().describe("Description of when to use the agent"),
mode: z.enum(["subagent", "primary", "all"]).optional(),
hidden: z
.boolean()
.optional()
.describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"),
options: z.record(z.string(), z.any()).optional(),
color: z
.union([
z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"),
z.enum(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
.optional()
.describe("Hex color code (e.g., #FF5733) or theme color (e.g., primary)"),
steps: z
.number()
.int()
.positive()
.optional()
.describe("Maximum number of agentic iterations before forcing text-only response"),
maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."),
permission: ConfigPermission.Info.optional(),
})
.catchall(z.any())
.transform((agent, _ctx) => {
const knownKeys = new Set([
"name",
"model",
"variant",
"prompt",
"description",
"temperature",
"top_p",
"mode",
"hidden",
"color",
"steps",
"maxSteps",
"options",
"permission",
"disable",
"tools",
])

const options: Record<string, unknown> = { ...agent.options }
for (const [key, value] of Object.entries(agent)) {
if (!knownKeys.has(key)) options[key] = value
}
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))

const permission: ConfigPermission.Info = {}
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
const action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
permission.edit = action
continue
}
permission[tool] = action
}
Object.assign(permission, agent.permission)
const Color = Schema.Union([
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])

// ConfigPermission.Info is a zod schema (its `.preprocess(...).transform(...)`
// shape lives outside the Effect Schema type system), so the walker reaches it
// via ZodOverride rather than a pure Schema reference. This preserves the
// `$ref: PermissionConfig` emitted in openapi.json.
const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })

const AgentSchema = Schema.StructWithRest(
Schema.Struct({
model: Schema.optional(ConfigModelID),
variant: Schema.optional(Schema.String).annotate({
description: "Default model variant for this agent (applies only when using the agent's configured model).",
}),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
prompt: Schema.optional(Schema.String),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
description: "@deprecated Use 'permission' field instead",
}),
disable: Schema.optional(Schema.Boolean),
description: Schema.optional(Schema.String).annotate({ description: "Description of when to use the agent" }),
mode: Schema.optional(Schema.Literals(["subagent", "primary", "all"])),
hidden: Schema.optional(Schema.Boolean).annotate({
description: "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)",
}),
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
color: Schema.optional(Color).annotate({
description: "Hex color code (e.g., #FF5733) or theme color (e.g., primary)",
}),
steps: Schema.optional(PositiveInt).annotate({
description: "Maximum number of agentic iterations before forcing text-only response",
}),
maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }),
permission: Schema.optional(PermissionRef),
}),
[Schema.Record(Schema.String, Schema.Any)],
)

const steps = agent.steps ?? agent.maxSteps
const KNOWN_KEYS = new Set([
"name",
"model",
"variant",
"prompt",
"description",
"temperature",
"top_p",
"mode",
"hidden",
"color",
"steps",
"maxSteps",
"options",
"permission",
"disable",
"tools",
])

return { ...agent, options, permission, steps } as typeof agent & {
options?: Record<string, unknown>
permission?: ConfigPermission.Info
steps?: number
// Post-parse normalisation:
// - Promote any unknown-but-present keys into `options` so they survive the
// round-trip in a well-known field.
// - Translate the deprecated `tools: { name: boolean }` map into the new
// `permission` shape (write-adjacent tools collapse into `permission.edit`).
// - Coalesce `steps ?? maxSteps` so downstream can ignore the deprecated alias.
const normalize = (agent: z.infer<typeof Info>) => {
const options: Record<string, unknown> = { ...agent.options }
for (const [key, value] of Object.entries(agent)) {
if (!KNOWN_KEYS.has(key)) options[key] = value
}

const permission: ConfigPermission.Info = {}
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
const action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
permission.edit = action
continue
}
})
.meta({
ref: "AgentConfig",
})
permission[tool] = action
}
globalThis.Object.assign(permission, agent.permission)

return { ...agent, options, permission, steps: agent.steps ?? agent.maxSteps }
}

export const Info = zod(AgentSchema)
.transform(normalize)
.meta({ ref: "AgentConfig" }) as unknown as z.ZodType<
Omit<z.infer<ReturnType<typeof zod<typeof AgentSchema>>>, "options" | "permission" | "steps"> & {
options?: Record<string, unknown>
permission?: ConfigPermission.Info
steps?: number
}
>
export type Info = z.infer<typeof Info>

export async function load(dir: string) {
Expand Down
Loading