From a826936702251df6a88d90f32f8570e68a4e7995 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 9 Jul 2025 15:44:59 -0400 Subject: [PATCH] modes concept --- packages/opencode/src/config/config.ts | 19 +++++ packages/opencode/src/server/server.ts | 21 ++++++ packages/opencode/src/session/index.ts | 27 +++++-- packages/opencode/src/session/message-v2.ts | 1 + packages/opencode/src/session/mode.ts | 74 +++++++++++++++++++ packages/opencode/src/session/prompt/plan.txt | 3 + 6 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/src/session/mode.ts create mode 100644 packages/opencode/src/session/prompt/plan.txt diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7c248da8..9d6ca2ca 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -55,6 +55,17 @@ export namespace Config { export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote]) export type Mcp = z.infer + export const Mode = z + .object({ + model: z.string().optional(), + prompt: z.string().optional(), + tools: z.record(z.string(), z.boolean()).optional(), + }) + .openapi({ + ref: "ModeConfig", + }) + export type Mode = z.infer + export const Keybinds = z .object({ leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"), @@ -99,6 +110,7 @@ export namespace Config { .openapi({ ref: "KeybindsConfig", }) + export const Info = z .object({ $schema: z.string().optional().describe("JSON schema reference for configuration validation"), @@ -108,6 +120,13 @@ export namespace Config { autoupdate: z.boolean().optional().describe("Automatically update to the latest version"), disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"), model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), + mode: z + .object({ + build: Mode.optional(), + plan: Mode.optional(), + }) + .catchall(Mode) + .optional(), log_level: Log.Level.optional().describe("Minimum log level to write to log files"), provider: z .record( diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 38a80897..db6a8fdf 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -16,6 +16,7 @@ import { Config } from "../config/config" import { File } from "../file" import { LSP } from "../lsp" import { MessageV2 } from "../session/message-v2" +import { Mode } from "../session/mode" const ERRORS = { 400: { @@ -681,6 +682,26 @@ export namespace Server { return c.json(true) }, ) + .get( + "/mode", + describeRoute({ + description: "List all modes", + responses: { + 200: { + description: "List of modes", + content: { + "application/json": { + schema: resolver(Mode.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const modes = await Mode.list() + return c.json(modes) + }, + ) return result } diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index a1a1d183..0e1861f2 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -15,6 +15,7 @@ import { } from "ai" import PROMPT_INITIALIZE from "../session/prompt/initialize.txt" +import PROMPT_PLAN from "../session/prompt/plan.txt" import { App } from "../app/app" import { Bus } from "../bus" @@ -29,12 +30,12 @@ import type { ModelsDev } from "../provider/models" import { Share } from "../share/share" import { Snapshot } from "../snapshot" import { Storage } from "../storage/storage" -import type { Tool } from "../tool/tool" import { Log } from "../util/log" import { NamedError } from "../util/error" import { SystemPrompt } from "./system" import { FileTime } from "../file/time" import { MessageV2 } from "./message-v2" +import { Mode } from "./mode" export namespace Session { const log = Log.create({ service: "session" }) @@ -281,13 +282,13 @@ export namespace Session { sessionID: string providerID: string modelID: string + mode?: string parts: MessageV2.UserPart[] - system?: string[] - tools?: Tool.Info[] }) { using abort = lock(input.sessionID) const l = log.clone().tag("session", input.sessionID) l.info("chatting") + const model = await Provider.getModel(input.providerID, input.modelID) let msgs = await messages(input.sessionID) const session = await get(input.sessionID) @@ -364,6 +365,7 @@ export namespace Session { return [ { type: "text", + synthetic: true, text: ["Called the Read tool on " + url.pathname, "", text, ""].join("\n"), }, ] @@ -373,6 +375,7 @@ export namespace Session { { type: "text", text: `Called the Read tool with the following input: {\"filePath\":\"${url.pathname}\"}`, + synthetic: true, }, { type: "file", @@ -386,6 +389,14 @@ export namespace Session { return [part] }), ).then((x) => x.flat()) + + if (true) + input.parts.push({ + type: "text", + text: PROMPT_PLAN, + synthetic: true, + }) + if (msgs.length === 0 && !session.parentID) { generateText({ maxOutputTokens: input.providerID === "google" ? 1024 : 20, @@ -431,9 +442,13 @@ export namespace Session { await updateMessage(msg) msgs.push(msg) - const system = input.system ?? SystemPrompt.provider(input.providerID, input.modelID) + const mode = await Mode.get(input.mode ?? "build") + let system = mode.prompt ? [mode.prompt] : SystemPrompt.provider(input.providerID, input.modelID) system.push(...(await SystemPrompt.environment())) system.push(...(await SystemPrompt.custom())) + // max 2 system prompt messages for caching purposes + const [first, ...rest] = system + system = [first, rest.join("\n")] const next: MessageV2.Info = { id: Identifier.ascending("message"), @@ -462,7 +477,8 @@ export namespace Session { const tools: Record = {} for (const item of await Provider.tools(input.providerID)) { - tools[item.id.replaceAll(".", "_")] = tool({ + if (mode.tools[item.id] === false) continue + tools[item.id] = tool({ id: item.id as any, description: item.description, inputSchema: item.parameters as ZodSchema, @@ -494,6 +510,7 @@ export namespace Session { } for (const [key, item] of Object.entries(await MCP.tools())) { + if (mode.tools[key] === false) continue const execute = item.execute if (!execute) continue item.execute = async (args, opts) => { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 8b09e68e..fba34f4c 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -76,6 +76,7 @@ export namespace MessageV2 { .object({ type: z.literal("text"), text: z.string(), + synthetic: z.boolean().optional(), }) .openapi({ ref: "TextPart", diff --git a/packages/opencode/src/session/mode.ts b/packages/opencode/src/session/mode.ts new file mode 100644 index 00000000..d2f54858 --- /dev/null +++ b/packages/opencode/src/session/mode.ts @@ -0,0 +1,74 @@ +import { mergeDeep } from "remeda" +import { App } from "../app/app" +import { Config } from "../config/config" +import z from "zod" + +export namespace Mode { + export const Info = z + .object({ + name: z.string(), + model: z + .object({ + modelID: z.string(), + providerID: z.string(), + }) + .optional(), + prompt: z.string().optional(), + tools: z + .object({ + write: z.boolean().optional(), + edit: z.boolean().optional(), + patch: z.boolean().optional(), + }) + .optional(), + }) + .openapi({ + ref: "Mode", + }) + export type Info = z.infer + const state = App.state("mode", async () => { + const cfg = await Config.get() + const mode = mergeDeep( + { + build: {}, + plan: { + tools: { + write: false, + edit: false, + patch: false, + }, + }, + }, + cfg.mode ?? {}, + ) + const result: Record = {} + for (const [key, value] of Object.entries(mode)) { + let item = result[key] + if (!item) + item = result[key] = { + name: key, + tools: {}, + } + const model = value.model ?? cfg.model + if (model) { + const [providerID, ...rest] = model.split("/") + const modelID = rest.join("/") + item.model = { + modelID, + providerID, + } + } + if (value.prompt) item.prompt = await Bun.file(value.prompt).text() + if (value.tools) item.tools = value.tools + } + return result + }) + + export async function get(mode: string) { + return state().then((x) => x[mode]) + } + + export async function list() { + return state().then((x) => Object.values(x)) + } +} diff --git a/packages/opencode/src/session/prompt/plan.txt b/packages/opencode/src/session/prompt/plan.txt new file mode 100644 index 00000000..fffbfffc --- /dev/null +++ b/packages/opencode/src/session/prompt/plan.txt @@ -0,0 +1,3 @@ + +Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received (for example, to make edits). +