From 8f6c8844d742b56858823e57388e8149f665cb7a Mon Sep 17 00:00:00 2001 From: Matt Silverlock Date: Sat, 20 Dec 2025 12:46:48 -0500 Subject: [PATCH] feat: support configuring a default_agent across all API/user surfaces (#5843) Co-authored-by: observerw --- github/action.yml | 5 ++++ github/index.ts | 28 ++++++++++++++++++- packages/opencode/src/acp/agent.ts | 6 ++-- packages/opencode/src/agent/agent.ts | 23 +++++++++++++++ packages/opencode/src/cli/cmd/github.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 28 +++++++++++++++++-- .../src/cli/cmd/tui/context/local.tsx | 2 +- packages/opencode/src/config/config.ts | 6 ++++ packages/opencode/src/server/server.ts | 4 +-- packages/opencode/src/session/prompt.ts | 6 ++-- packages/sdk/js/src/v2/gen/types.gen.ts | 5 ++++ packages/sdk/openapi.json | 7 +++++ packages/web/src/content/docs/config.mdx | 17 +++++++++++ packages/web/src/content/docs/github.mdx | 1 + 14 files changed, 128 insertions(+), 12 deletions(-) diff --git a/github/action.yml b/github/action.yml index cf276b51c8..57e26d8566 100644 --- a/github/action.yml +++ b/github/action.yml @@ -9,6 +9,10 @@ inputs: description: "Model to use" required: true + agent: + description: "Agent to use. Must be a primary agent. Falls back to default_agent from config or 'build' if not found." + required: false + share: description: "Share the opencode session (defaults to true for public repos)" required: false @@ -62,6 +66,7 @@ runs: run: opencode github run env: MODEL: ${{ inputs.model }} + AGENT: ${{ inputs.agent }} SHARE: ${{ inputs.share }} PROMPT: ${{ inputs.prompt }} USE_GITHUB_TOKEN: ${{ inputs.use_github_token }} diff --git a/github/index.ts b/github/index.ts index 6d826326ed..7f60182329 100644 --- a/github/index.ts +++ b/github/index.ts @@ -318,6 +318,10 @@ function useEnvRunUrl() { return `/${repo.owner}/${repo.repo}/actions/runs/${runId}` } +function useEnvAgent() { + return process.env["AGENT"] || undefined +} + function useEnvShare() { const value = process.env["SHARE"] if (!value) return undefined @@ -578,16 +582,38 @@ async function summarize(response: string) { } } +async function resolveAgent(): Promise { + const envAgent = useEnvAgent() + if (!envAgent) return undefined + + // Validate the agent exists and is a primary agent + const agents = await client.agent.list() + const agent = agents.data?.find((a) => a.name === envAgent) + + if (!agent) { + console.warn(`agent "${envAgent}" not found. Falling back to default agent`) + return undefined + } + + if (agent.mode === "subagent") { + console.warn(`agent "${envAgent}" is a subagent, not a primary agent. Falling back to default agent`) + return undefined + } + + return envAgent +} + async function chat(text: string, files: PromptFiles = []) { console.log("Sending message to opencode...") const { providerID, modelID } = useEnvModel() + const agent = await resolveAgent() const chat = await client.session.chat({ path: session, body: { providerID, modelID, - agent: "build", + agent, parts: [ { type: "text", diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 2817adf5d1..e6419dd766 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -22,6 +22,7 @@ import { Log } from "../util/log" import { ACPSessionManager } from "./session" import type { ACPConfig, ACPSessionState } from "./types" import { Provider } from "../provider/provider" +import { Agent as AgentModule } from "../agent/agent" import { Installation } from "@/installation" import { MessageV2 } from "@/session/message-v2" import { Config } from "@/config/config" @@ -705,7 +706,8 @@ export namespace ACP { description: agent.description, })) - const currentModeId = availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id + const defaultAgentName = await AgentModule.defaultAgent() + const currentModeId = availableModes.find((m) => m.name === defaultAgentName)?.id ?? availableModes[0].id const mcpServers: Record = {} for (const server of params.mcpServers) { @@ -807,7 +809,7 @@ export namespace ACP { if (!current) { this.sessionManager.setModel(session.id, model) } - const agent = session.modeId ?? "build" + const agent = session.modeId ?? (await AgentModule.defaultAgent()) const parts: Array< { type: "text"; text: string } | { type: "file"; url: string; filename: string; mime: string } diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index add120f910..26f241fabb 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -5,6 +5,9 @@ import { generateObject, type ModelMessage } from "ai" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" import { mergeDeep } from "remeda" +import { Log } from "../util/log" + +const log = Log.create({ service: "agent" }) import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" @@ -20,6 +23,7 @@ export namespace Agent { mode: z.enum(["subagent", "primary", "all"]), native: z.boolean().optional(), hidden: z.boolean().optional(), + default: z.boolean().optional(), topP: z.number().optional(), temperature: z.number().optional(), color: z.string().optional(), @@ -245,6 +249,19 @@ export namespace Agent { item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {}) } } + + // Mark the default agent + const defaultName = cfg.default_agent ?? "build" + const defaultCandidate = result[defaultName] + if (defaultCandidate && defaultCandidate.mode !== "subagent") { + defaultCandidate.default = true + } else { + // Fall back to "build" if configured default is invalid + if (result["build"]) { + result["build"].default = true + } + } + return result }) @@ -256,6 +273,12 @@ export namespace Agent { return state().then((x) => Object.values(x)) } + export async function defaultAgent(): Promise { + const agents = await state() + const defaultCandidate = Object.values(agents).find((a) => a.default) + return defaultCandidate?.name ?? "build" + } + export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) { const cfg = await Config.get() const defaultModel = input.model ?? (await Provider.defaultModel()) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index f4f026d4c3..26340044c8 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -762,7 +762,7 @@ export const GithubRunCommand = cmd({ providerID, modelID, }, - agent: "build", + // agent is omitted - server will use default_agent from config or fall back to "build" parts: [ { id: Identifier.ascending("part"), diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3a0b2f23fb..0c371b864c 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -10,6 +10,7 @@ import { select } from "@clack/prompts" import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2" import { Server } from "../../server/server" import { Provider } from "../../provider/provider" +import { Agent } from "../../agent/agent" const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], @@ -223,10 +224,33 @@ export const RunCommand = cmd({ } })() + // Validate agent if specified + const resolvedAgent = await (async () => { + if (!args.agent) return undefined + const agent = await Agent.get(args.agent) + if (!agent) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${args.agent}" not found. Falling back to default agent`, + ) + return undefined + } + if (agent.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + return args.agent + })() + if (args.command) { await sdk.session.command({ sessionID, - agent: args.agent || "build", + agent: resolvedAgent, model: args.model, command: args.command, arguments: message, @@ -235,7 +259,7 @@ export const RunCommand = cmd({ const modelParam = args.model ? Provider.parseModel(args.model) : undefined await sdk.session.prompt({ sessionID, - agent: args.agent || "build", + agent: resolvedAgent, model: modelParam, parts: [...fileParts, { type: "text", text: message }], }) diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index f04b79685c..55c04621ef 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -56,7 +56,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const [agentStore, setAgentStore] = createStore<{ current: string }>({ - current: agents()[0].name, + current: agents().find((x) => x.default)?.name ?? agents()[0].name, }) const { theme } = useTheme() const colors = createMemo(() => [ diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a01cc832a0..031bdd31ba 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -666,6 +666,12 @@ export namespace Config { .string() .describe("Small model to use for tasks like title generation in the format of provider/model") .optional(), + default_agent: z + .string() + .optional() + .describe( + "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.", + ), username: z .string() .optional() diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 77bf5085b5..2f4b3b221c 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1060,11 +1060,11 @@ export namespace Server { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") const msgs = await Session.messages({ sessionID }) - let currentAgent = "build" + let currentAgent = await Agent.defaultAgent() for (let i = msgs.length - 1; i >= 0; i--) { const info = msgs[i].info if (info.role === "user") { - currentAgent = info.agent || "build" + currentAgent = info.agent || (await Agent.defaultAgent()) break } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index cbb3eedf34..ebd54a6c80 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -715,7 +715,7 @@ export namespace SessionPrompt { } async function createUserMessage(input: PromptInput) { - const agent = await Agent.get(input.agent ?? "build") + const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) const info: MessageV2.Info = { id: input.messageID ?? Identifier.ascending("message"), role: "user", @@ -1282,7 +1282,7 @@ export namespace SessionPrompt { export async function command(input: CommandInput) { log.info("command", input) const command = await Command.get(input.command) - const agentName = command.agent ?? input.agent ?? "build" + const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) const raw = input.arguments.match(argsRegex) ?? [] const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) @@ -1425,7 +1425,7 @@ export namespace SessionPrompt { time: { created: Date.now(), }, - agent: input.message.info.role === "user" ? input.message.info.agent : "build", + agent: input.message.info.role === "user" ? input.message.info.agent : await Agent.defaultAgent(), model: { providerID: input.providerID, modelID: input.modelID, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index cdbdfdfda1..1b43d3f48a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1414,6 +1414,10 @@ export type Config = { * Small model to use for tasks like title generation in the format of provider/model */ small_model?: string + /** + * Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid. + */ + default_agent?: string /** * Custom username to display in conversations instead of system username */ @@ -1767,6 +1771,7 @@ export type Agent = { mode: "subagent" | "primary" | "all" native?: boolean hidden?: boolean + default?: boolean topP?: number temperature?: number color?: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index eeb81a8443..f33d20069c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8153,6 +8153,10 @@ "description": "Small model to use for tasks like title generation in the format of provider/model", "type": "string" }, + "default_agent": { + "description": "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.", + "type": "string" + }, "username": { "description": "Custom username to display in conversations instead of system username", "type": "string" @@ -9152,6 +9156,9 @@ "hidden": { "type": "boolean" }, + "default": { + "type": "boolean" + }, "topP": { "type": "number" }, diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 302d79d172..5ba22ff2d7 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -194,6 +194,23 @@ You can also define agents using markdown files in `~/.config/opencode/agent/` o --- +### Default agent + +You can set the default agent using the `default_agent` option. This determines which agent is used when none is explicitly specified. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "default_agent": "plan" +} +``` + +The default agent must be a primary agent (not a subagent). This can be a built-in agent like `"build"` or `"plan"`, or a [custom agent](/docs/agents) you've defined. If the specified agent doesn't exist or is a subagent, OpenCode will fall back to `"build"` with a warning. + +This setting applies across all interfaces: TUI, CLI (`opencode run`), desktop app, and GitHub Action. + +--- + ### Sharing You can configure the [share](/docs/share) feature through the `share` option. diff --git a/packages/web/src/content/docs/github.mdx b/packages/web/src/content/docs/github.mdx index a38df68f4d..1d60788400 100644 --- a/packages/web/src/content/docs/github.mdx +++ b/packages/web/src/content/docs/github.mdx @@ -81,6 +81,7 @@ Or you can set it up manually. ## Configuration - `model`: The model to use with OpenCode. Takes the format of `provider/model`. This is **required**. +- `agent`: The agent to use. Must be a primary agent. Falls back to `default_agent` from config or `"build"` if not found. - `share`: Whether to share the OpenCode session. Defaults to **true** for public repositories. - `prompt`: Optional custom prompt to override the default behavior. Use this to customize how OpenCode processes requests. - `token`: Optional GitHub access token for performing operations such as creating comments, committing changes, and opening pull requests. By default, OpenCode uses the installation access token from the OpenCode GitHub App, so commits, comments, and pull requests appear as coming from the app.