From 8dcd39f5b72f85c652853bac111eeabfeab7baf5 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 24 Jul 2025 21:20:43 -0400 Subject: [PATCH] real life totally configurabl ai subasians --- .gitignore | 2 +- bun.lock | 17 ++ packages/opencode/package.json | 1 + packages/opencode/src/agent/agent.ts | 99 +++++++++++ packages/opencode/src/agent/generate.txt | 75 +++++++++ packages/opencode/src/cli/cmd/agent.ts | 110 ++++++++++++ packages/opencode/src/config/config.ts | 40 +++++ packages/opencode/src/file/ripgrep.ts | 1 + packages/opencode/src/index.ts | 2 + packages/opencode/src/provider/provider.ts | 47 +++--- packages/opencode/src/session/index.ts | 25 ++- packages/opencode/src/session/mode.ts | 43 ++--- packages/opencode/src/session/system.ts | 4 + packages/opencode/src/tool/multiedit.ts | 14 +- packages/opencode/src/tool/task.ts | 115 +++++++------ packages/opencode/src/tool/task.txt | 48 +++++- packages/opencode/src/tool/tool.ts | 6 +- packages/opencode/src/util/filesystem.ts | 3 +- packages/opencode/test/tool/tool.test.ts | 9 +- packages/web/astro.config.mjs | 1 + packages/web/src/content/docs/docs/agents.mdx | 158 ++++++++++++++++++ packages/web/src/content/docs/docs/config.mdx | 31 +++- 22 files changed, 729 insertions(+), 122 deletions(-) create mode 100644 packages/opencode/src/agent/agent.ts create mode 100644 packages/opencode/src/agent/generate.txt create mode 100644 packages/opencode/src/cli/cmd/agent.ts create mode 100644 packages/web/src/content/docs/docs/agents.mdx diff --git a/.gitignore b/.gitignore index 27316da64..1c88d3d59 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ .DS_Store node_modules -.opencode .sst .env .idea .vscode openapi.json +scratch diff --git a/bun.lock b/bun.lock index 6ea2b02c5..a47e1d487 100644 --- a/bun.lock +++ b/bun.lock @@ -38,6 +38,7 @@ "ai": "catalog:", "decimal.js": "10.5.0", "diff": "8.0.2", + "gray-matter": "4.0.3", "hono": "4.7.10", "hono-openapi": "0.4.8", "isomorphic-git": "1.32.1", @@ -1155,6 +1156,8 @@ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -1245,6 +1248,8 @@ "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + "h3": ["h3@1.15.3", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.4", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.0", "radix3": "^1.1.2", "ufo": "^1.6.1", "uncrypto": "^0.1.3" } }, "sha512-z6GknHqyX0h9aQaTx22VZDf6QyZn+0Nh+Ym8O/u0SGSkyF5cuTJYKlc8MkzW3Nzf9LE1ivcpmYC3FUGpywhuUQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -1367,6 +1372,8 @@ "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -1497,6 +1504,8 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="], @@ -1969,6 +1978,8 @@ "sax": ["sax@1.2.1", "", {}, "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA=="], + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], @@ -2077,6 +2088,8 @@ "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], + "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], @@ -2449,6 +2462,8 @@ "glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + "gray-matter/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], + "hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], @@ -2649,6 +2664,8 @@ "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "ignore-walk/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "jest-changed-files/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f278c636c..6e1e0f8da 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -36,6 +36,7 @@ "ai": "catalog:", "decimal.js": "10.5.0", "diff": "8.0.2", + "gray-matter": "4.0.3", "hono": "4.7.10", "hono-openapi": "0.4.8", "isomorphic-git": "1.32.1", diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts new file mode 100644 index 000000000..27e3d51da --- /dev/null +++ b/packages/opencode/src/agent/agent.ts @@ -0,0 +1,99 @@ +import { App } from "../app/app" +import { Config } from "../config/config" +import z from "zod" +import { Provider } from "../provider/provider" +import { generateObject, type ModelMessage } from "ai" +import PROMPT_GENERATE from "./generate.txt" +import { SystemPrompt } from "../session/system" + +export namespace Agent { + export const Info = z + .object({ + name: z.string(), + model: z + .object({ + modelID: z.string(), + providerID: z.string(), + }) + .optional(), + description: z.string(), + prompt: z.string().optional(), + tools: z.record(z.boolean()), + }) + .openapi({ + ref: "Agent", + }) + export type Info = z.infer + const state = App.state("agent", async () => { + const cfg = await Config.get() + const result: Record = { + general: { + name: "general", + description: + "General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.", + tools: { + todoread: false, + todowrite: false, + }, + }, + } + for (const [key, value] of Object.entries(cfg.agent ?? {})) { + if (value.disable) continue + let item = result[key] + if (!item) + item = result[key] = { + name: key, + description: "", + tools: {}, + } + const model = value.model ?? cfg.model + if (model) item.model = Provider.parseModel(model) + if (value.prompt) item.prompt = value.prompt + if (value.tools) + item.tools = { + ...item.tools, + ...value.tools, + } + if (value.description) item.description = value.description + } + return result + }) + + export async function get(agent: string) { + return state().then((x) => x[agent]) + } + + export async function list() { + return state().then((x) => Object.values(x)) + } + + export async function generate(input: { description: string }) { + const defaultModel = await Provider.defaultModel() + const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID) + const system = SystemPrompt.header(defaultModel.providerID) + system.push(PROMPT_GENERATE) + const existing = await list() + const result = await generateObject({ + temperature: 0.3, + prompt: [ + ...system.map( + (item): ModelMessage => ({ + role: "system", + content: item, + }), + ), + { + role: "user", + content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`, + }, + ], + model: model.language, + schema: z.object({ + identifier: z.string(), + whenToUse: z.string(), + systemPrompt: z.string(), + }), + }) + return result.object + } +} diff --git a/packages/opencode/src/agent/generate.txt b/packages/opencode/src/agent/generate.txt new file mode 100644 index 000000000..774277b0f --- /dev/null +++ b/packages/opencode/src/agent/generate.txt @@ -0,0 +1,75 @@ +You are an elite AI agent architect specializing in crafting high-performance agent configurations. Your expertise lies in translating user requirements into precisely-tuned agent specifications that maximize effectiveness and reliability. + +**Important Context**: You may have access to project-specific instructions from CLAUDE.md files and other context that may include coding standards, project structure, and custom requirements. Consider this context when creating agents to ensure they align with the project's established patterns and practices. + +When a user describes what they want an agent to do, you will: + +1. **Extract Core Intent**: Identify the fundamental purpose, key responsibilities, and success criteria for the agent. Look for both explicit requirements and implicit needs. Consider any project-specific context from CLAUDE.md files. For agents that are meant to review code, you should assume that the user is asking to review recently written code and not the whole codebase, unless the user has explicitly instructed you otherwise. + +2. **Design Expert Persona**: Create a compelling expert identity that embodies deep domain knowledge relevant to the task. The persona should inspire confidence and guide the agent's decision-making approach. + +3. **Architect Comprehensive Instructions**: Develop a system prompt that: + + - Establishes clear behavioral boundaries and operational parameters + - Provides specific methodologies and best practices for task execution + - Anticipates edge cases and provides guidance for handling them + - Incorporates any specific requirements or preferences mentioned by the user + - Defines output format expectations when relevant + - Aligns with project-specific coding standards and patterns from CLAUDE.md + +4. **Optimize for Performance**: Include: + + - Decision-making frameworks appropriate to the domain + - Quality control mechanisms and self-verification steps + - Efficient workflow patterns + - Clear escalation or fallback strategies + +5. **Create Identifier**: Design a concise, descriptive identifier that: + - Uses lowercase letters, numbers, and hyphens only + - Is typically 2-4 words joined by hyphens + - Clearly indicates the agent's primary function + - Is memorable and easy to type + - Avoids generic terms like "helper" or "assistant" + +6 **Example agent descriptions**: + +- in the 'whenToUse' field of the JSON object, you should include examples of when this agent should be used. +- examples should be of the form: + - + Context: The user is creating a code-review agent that should be called after a logical chunk of code is written. + user: "Please write a function that checks if a number is prime" + assistant: "Here is the relevant function: " + + + Since the user is greeting, use the Task tool to launch the greeting-responder agent to respond with a friendly joke. + + assistant: "Now let me use the code-reviewer agent to review the code" + + - + Context: User is creating an agent to respond to the word "hello" with a friendly jok. + user: "Hello" + assistant: "I'm going to use the Task tool to launch the greeting-responder agent to respond with a friendly joke" + + Since the user is greeting, use the greeting-responder agent to respond with a friendly joke. + + +- If the user mentioned or implied that the agent should be used proactively, you should include examples of this. +- NOTE: Ensure that in the examples, you are making the assistant use the Agent tool and not simply respond directly to the task. + +Your output must be a valid JSON object with exactly these fields: +{ +"identifier": "A unique, descriptive identifier using lowercase letters, numbers, and hyphens (e.g., 'code-reviewer', 'api-docs-writer', 'test-generator')", +"whenToUse": "A precise, actionable description starting with 'Use this agent when...' that clearly defines the triggering conditions and use cases. Ensure you include examples as described above.", +"systemPrompt": "The complete system prompt that will govern the agent's behavior, written in second person ('You are...', 'You will...') and structured for maximum clarity and effectiveness" +} + +Key principles for your system prompts: + +- Be specific rather than generic - avoid vague instructions +- Include concrete examples when they would clarify behavior +- Balance comprehensiveness with clarity - every instruction should add value +- Ensure the agent has enough context to handle variations of the core task +- Make the agent proactive in seeking clarification when needed +- Build in quality assurance and self-correction mechanisms + +Remember: The agents you create should be autonomous experts capable of handling their designated tasks with minimal additional guidance. Your system prompts are their complete operational manual. diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts new file mode 100644 index 000000000..33b270ee5 --- /dev/null +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -0,0 +1,110 @@ +import { cmd } from "./cmd" +import * as prompts from "@clack/prompts" +import { UI } from "../ui" +import { Global } from "../../global" +import { Agent } from "../../agent/agent" +import path from "path" +import matter from "gray-matter" +import { App } from "../../app/app" + +const AgentCreateCommand = cmd({ + command: "create", + describe: "create a new agent", + async handler() { + await App.provide({ cwd: process.cwd() }, async (app) => { + UI.empty() + prompts.intro("Create agent") + + let scope: "global" | "project" = "global" + if (app.git) { + const scopeResult = await prompts.select({ + message: "Location", + options: [ + { + label: "Current project", + value: "project" as const, + hint: app.path.root, + }, + { + label: "Global", + value: "global" as const, + hint: Global.Path.config, + }, + ], + }) + if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() + scope = scopeResult + } + + const query = await prompts.text({ + message: "Description", + placeholder: "What should this agent do?", + validate: (x) => (x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(query)) throw new UI.CancelledError() + + const spinner = prompts.spinner() + + spinner.start("Generating agent configuration...") + const generated = await Agent.generate({ description: query }) + spinner.stop(`Agent ${generated.identifier} generated`) + + const availableTools = [ + "bash", + "read", + "write", + "edit", + "list", + "glob", + "grep", + "webfetch", + "task", + "todowrite", + "todoread", + ] + + const selectedTools = await prompts.multiselect({ + message: "Select tools to enable", + options: availableTools.map((tool) => ({ + label: tool, + value: tool, + })), + initialValues: availableTools, + }) + if (prompts.isCancel(selectedTools)) throw new UI.CancelledError() + + const tools: Record = {} + for (const tool of availableTools) { + if (!selectedTools.includes(tool)) { + tools[tool] = false + } + } + + const frontmatter: any = { + description: generated.whenToUse, + } + if (Object.keys(tools).length > 0) { + frontmatter.tools = tools + } + + const content = matter.stringify(generated.systemPrompt, frontmatter) + const filePath = path.join( + scope === "global" ? Global.Path.config : path.join(app.path.root, ".opencode"), + `agent`, + `${generated.identifier}.md`, + ) + + await Bun.write(filePath, content) + + prompts.log.success(`Agent created: ${filePath}`) + prompts.outro("Done") + }) + }, +}) + +export const AgentCommand = cmd({ + command: "agent", + describe: "manage agents", + builder: (yargs) => yargs.command(AgentCreateCommand).demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5020194c8..855ea56ca 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -9,6 +9,7 @@ import { Global } from "../global" import fs from "fs/promises" import { lazy } from "../util/lazy" import { NamedError } from "../util/error" +import matter from "gray-matter" export namespace Config { const log = Log.create({ service: "config" }) @@ -22,6 +23,31 @@ export namespace Config { } } + result.agent = result.agent || {} + const markdownAgents = [ + ...(await Filesystem.globUp("agent/*.md", Global.Path.config, Global.Path.config)), + ...(await Filesystem.globUp(".opencode/agent/*.md", app.path.cwd, app.path.root)), + ] + for (const item of markdownAgents) { + const content = await Bun.file(item).text() + const md = matter(content) + if (!md.data) continue + + const config = { + name: path.basename(item, ".md"), + ...md.data, + prompt: md.content.trim(), + } + const parsed = Agent.safeParse(config) + if (parsed.success) { + result.agent = mergeDeep(result.agent, { + [config.name]: parsed.data, + }) + continue + } + throw new InvalidError({ path: item }, { cause: parsed.error }) + } + // Handle migration from autoshare to share field if (result.autoshare === true && !result.share) { result.share = "auto" @@ -75,12 +101,19 @@ export namespace Config { model: z.string().optional(), prompt: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), + disable: z.boolean().optional(), }) .openapi({ ref: "ModeConfig", }) export type Mode = z.infer + export const Agent = Mode.extend({ + description: z.string(), + }).openapi({ + ref: "AgentConfig", + }) + export const Keybinds = z .object({ leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"), @@ -172,6 +205,13 @@ export namespace Config { .catchall(Mode) .optional() .describe("Modes configuration, see https://opencode.ai/docs/modes"), + agent: z + .object({ + general: Agent.optional(), + }) + .catchall(Agent) + .optional() + .describe("Modes configuration, see https://opencode.ai/docs/modes"), provider: z .record( ModelsDev.Provider.partial() diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index d8d5dd531..f21cbdef9 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -249,6 +249,7 @@ export namespace Ripgrep { children: [], } for (const file of files) { + if (file.includes(".opencode")) continue const parts = file.split(path.sep) getPath(root, parts, true) } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 0564e44d4..21224e7be 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -5,6 +5,7 @@ import { RunCommand } from "./cli/cmd/run" import { GenerateCommand } from "./cli/cmd/generate" import { Log } from "./util/log" import { AuthCommand } from "./cli/cmd/auth" +import { AgentCommand } from "./cli/cmd/agent" import { UpgradeCommand } from "./cli/cmd/upgrade" import { ModelsCommand } from "./cli/cmd/models" import { UI } from "./cli/ui" @@ -72,6 +73,7 @@ const cli = yargs(hideBin(process.argv)) .command(GenerateCommand) .command(DebugCommand) .command(AuthCommand) + .command(AgentCommand) .command(UpgradeCommand) .command(ServeCommand) .command(ModelsCommand) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 57fde28b1..93a08e256 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -13,7 +13,6 @@ import { GrepTool } from "../tool/grep" import { ListTool } from "../tool/ls" import { PatchTool } from "../tool/patch" import { ReadTool } from "../tool/read" -import type { Tool } from "../tool/tool" import { WriteTool } from "../tool/write" import { TodoReadTool, TodoWriteTool } from "../tool/todo" import { AuthAnthropic } from "../auth/anthropic" @@ -487,31 +486,29 @@ export namespace Provider { TaskTool, ] - const TOOL_MAPPING: Record = { - anthropic: TOOLS.filter((t) => t.id !== "patch"), - openai: TOOLS.map((t) => ({ - ...t, - parameters: optionalToNullable(t.parameters), - })), - azure: TOOLS.map((t) => ({ - ...t, - parameters: optionalToNullable(t.parameters), - })), - google: TOOLS.map((t) => ({ - ...t, - parameters: sanitizeGeminiParameters(t.parameters), - })), - } - export async function tools(providerID: string) { - /* - const cfg = await Config.get() - if (cfg.tool?.provider?.[providerID]) - return cfg.tool.provider[providerID].map( - (id) => TOOLS.find((t) => t.id === id)!, - ) - */ - return TOOL_MAPPING[providerID] ?? TOOLS + const result = await Promise.all(TOOLS.map((t) => t())) + switch (providerID) { + case "anthropic": + return result.filter((t) => t.id !== "patch") + case "openai": + return result.map((t) => ({ + ...t, + parameters: optionalToNullable(t.parameters), + })) + case "azure": + return result.map((t) => ({ + ...t, + parameters: optionalToNullable(t.parameters), + })) + case "google": + return result.map((t) => ({ + ...t, + parameters: sanitizeGeminiParameters(t.parameters), + })) + default: + return result + } } function sanitizeGeminiParameters(schema: z.ZodTypeAny, visited = new Set()): z.ZodTypeAny { diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index c37af2791..27fa310b9 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -17,7 +17,6 @@ import { import PROMPT_INITIALIZE from "../session/prompt/initialize.txt" import PROMPT_PLAN from "../session/prompt/plan.txt" -import PROMPT_ANTHROPIC_SPOOF from "../session/prompt/anthropic_spoof.txt" import { App } from "../app/app" import { Bus } from "../bus" @@ -337,6 +336,7 @@ export namespace Session { providerID: z.string(), modelID: z.string(), mode: z.string().optional(), + system: z.string().optional(), tools: z.record(z.boolean()).optional(), parts: z.array( z.discriminatedUnion("type", [ @@ -430,12 +430,14 @@ export namespace Session { } } const args = { filePath, offset, limit } - const result = await ReadTool.execute(args, { - sessionID: input.sessionID, - abort: new AbortController().signal, - messageID: userMsg.id, - metadata: async () => {}, - }) + const result = await ReadTool().then((t) => + t.execute(args, { + sessionID: input.sessionID, + abort: new AbortController().signal, + messageID: userMsg.id, + metadata: async () => {}, + }), + ) return [ { id: Identifier.ascending("part"), @@ -616,7 +618,14 @@ export namespace Session { } const mode = await Mode.get(inputMode) - let system = input.providerID === "anthropic" ? [PROMPT_ANTHROPIC_SPOOF.trim()] : [] + let system = SystemPrompt.header(input.providerID) + system.push( + ...(() => { + if (input.system) return [input.system] + if (mode.prompt) return [mode.prompt] + return SystemPrompt.provider(input.modelID) + })(), + ) system.push(...(mode.prompt ? [mode.prompt] : SystemPrompt.provider(input.modelID))) system.push(...(await SystemPrompt.environment())) system.push(...(await SystemPrompt.custom())) diff --git a/packages/opencode/src/session/mode.ts b/packages/opencode/src/session/mode.ts index eb9e69275..471357e16 100644 --- a/packages/opencode/src/session/mode.ts +++ b/packages/opencode/src/session/mode.ts @@ -1,7 +1,7 @@ -import { mergeDeep } from "remeda" import { App } from "../app/app" import { Config } from "../config/config" import z from "zod" +import { Provider } from "../provider/provider" export namespace Mode { export const Info = z @@ -22,38 +22,39 @@ export namespace 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, - }, + const result: Record = { + build: { + name: "build", + tools: {}, + }, + plan: { + name: "plan", + tools: { + write: false, + edit: false, + patch: false, }, }, - cfg.mode ?? {}, - ) - const result: Record = {} - for (const [key, value] of Object.entries(mode)) { + } + for (const [key, value] of Object.entries(cfg.mode ?? {})) { + if (value.disable) continue let item = result[key] if (!item) item = result[key] = { name: key, tools: {}, } + item.name = key const model = value.model ?? cfg.model if (model) { - const [providerID, ...rest] = model.split("/") - const modelID = rest.join("/") - item.model = { - modelID, - providerID, - } + item.model = Provider.parseModel(model) } if (value.prompt) item.prompt = value.prompt - if (value.tools) item.tools = value.tools + if (value.tools) + item.tools = { + ...value.tools, + ...item.tools, + } } return result diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 375b627bc..101182ed8 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -14,6 +14,10 @@ import PROMPT_SUMMARIZE from "./prompt/summarize.txt" import PROMPT_TITLE from "./prompt/title.txt" export namespace SystemPrompt { + export function header(providerID: string) { + if (providerID.includes("anthropic")) return [PROMPT_ANTHROPIC_SPOOF.trim()] + return [] + } export function provider(modelID: string) { if (modelID.includes("gpt-") || modelID.includes("o1") || modelID.includes("o3")) return [PROMPT_BEAST] if (modelID.includes("gemini-")) return [PROMPT_GEMINI] diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts index 041893b9c..4e6bff2bb 100644 --- a/packages/opencode/src/tool/multiedit.ts +++ b/packages/opencode/src/tool/multiedit.ts @@ -10,12 +10,22 @@ export const MultiEditTool = Tool.define({ description: DESCRIPTION, parameters: z.object({ filePath: z.string().describe("The absolute path to the file to modify"), - edits: z.array(EditTool.parameters).describe("Array of edit operations to perform sequentially on the file"), + edits: z + .array( + z.object({ + filePath: z.string().describe("The absolute path to the file to modify"), + oldString: z.string().describe("The text to replace"), + newString: z.string().describe("The text to replace it with (must be different from oldString)"), + replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), + }), + ) + .describe("Array of edit operations to perform sequentially on the file"), }), async execute(params, ctx) { + const tool = await EditTool() const results = [] for (const [, edit] of params.edits.entries()) { - const result = await EditTool.execute( + const result = await tool.execute( { filePath: params.filePath, oldString: edit.oldString, diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 75ad48d40..8398b350f 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -5,62 +5,71 @@ import { Session } from "../session" import { Bus } from "../bus" import { MessageV2 } from "../session/message-v2" import { Identifier } from "../id/id" +import { Agent } from "../agent/agent" -export const TaskTool = Tool.define({ - id: "task", - description: DESCRIPTION, - parameters: z.object({ - description: z.string().describe("A short (3-5 words) description of the task"), - prompt: z.string().describe("The task for the agent to perform"), - }), - async execute(params, ctx) { - const session = await Session.create(ctx.sessionID) - const msg = await Session.getMessage(ctx.sessionID, ctx.messageID) - if (msg.role !== "assistant") throw new Error("Not an assistant message") +export const TaskTool = Tool.define(async () => { + const agents = await Agent.list() + const description = DESCRIPTION.replace("{agents}", agents.map((a) => `- ${a.name}: ${a.description}`).join("\n")) + return { + id: "task", + description, + parameters: z.object({ + description: z.string().describe("A short (3-5 words) description of the task"), + prompt: z.string().describe("The task for the agent to perform"), + subagent_type: z.string().describe("The type of specialized agent to use for this task"), + }), + async execute(params, ctx) { + const session = await Session.create(ctx.sessionID) + const msg = await Session.getMessage(ctx.sessionID, ctx.messageID) + if (msg.role !== "assistant") throw new Error("Not an assistant message") + const agent = await Agent.get(params.subagent_type) + const messageID = Identifier.ascending("message") + const parts: Record = {} + const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { + if (evt.properties.part.sessionID !== session.id) return + if (evt.properties.part.messageID === messageID) return + if (evt.properties.part.type !== "tool") return + parts[evt.properties.part.id] = evt.properties.part + ctx.metadata({ + title: params.description, + metadata: { + summary: Object.values(parts).sort((a, b) => a.id?.localeCompare(b.id)), + }, + }) + }) - const messageID = Identifier.ascending("message") - const parts: Record = {} - const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { - if (evt.properties.part.sessionID !== session.id) return - if (evt.properties.part.messageID === messageID) return - if (evt.properties.part.type !== "tool") return - parts[evt.properties.part.id] = evt.properties.part - ctx.metadata({ + const model = agent.model ?? { + modelID: msg.modelID, + providerID: msg.providerID, + } + + ctx.abort.addEventListener("abort", () => { + Session.abort(session.id) + }) + const result = await Session.chat({ + messageID, + sessionID: session.id, + modelID: model.modelID, + providerID: model.providerID, + mode: msg.mode, + system: agent.prompt, + tools: agent.tools, + parts: [ + { + id: Identifier.ascending("part"), + type: "text", + text: params.prompt, + }, + ], + }) + unsub() + return { title: params.description, metadata: { - summary: Object.values(parts).sort((a, b) => a.id?.localeCompare(b.id)), + summary: result.parts.filter((x) => x.type === "tool"), }, - }) - }) - - ctx.abort.addEventListener("abort", () => { - Session.abort(session.id) - }) - const result = await Session.chat({ - messageID, - sessionID: session.id, - modelID: msg.modelID, - providerID: msg.providerID, - mode: msg.mode, - tools: { - todoread: false, - todowrite: false, - }, - parts: [ - { - id: Identifier.ascending("part"), - type: "text", - text: params.prompt, - }, - ], - }) - unsub() - return { - title: params.description, - metadata: { - summary: result.parts.filter((x) => x.type === "tool"), - }, - output: result.parts.findLast((x) => x.type === "text")!.text, - } - }, + output: result.parts.findLast((x) => x.type === "text")!.text, + } + }, + } }) diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt index c2fb9ff6a..508ec9d66 100644 --- a/packages/opencode/src/tool/task.txt +++ b/packages/opencode/src/tool/task.txt @@ -1,12 +1,19 @@ -Launch a new agent that has access to the following tools: Bash, Glob, Grep, LS, Read, Edit, MultiEdit, Write, NotebookRead, NotebookEdit, WebFetch, TodoRead, TodoWrite, WebSearch. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries, use the Agent tool to perform the search for you. +Launch a new agent to handle complex, multi-step tasks autonomously. + +Available agent types and the tools they have access to: +{agents} + +When using the Task tool, you must specify a subagent_type parameter to select which agent type to use. When to use the Agent tool: -- If you are searching for a keyword like "config" or "logger", or for questions like "which file does X?", the Agent tool is strongly recommended +- When you are instructed to execute custom slash commands. Use the Agent tool with the slash command invocation as the entire prompt. The slash command can take arguments. For example: Task(description="Check the file", prompt="/check-file path/to/file.py") When NOT to use the Agent tool: - If you want to read a specific file path, use the Read or Glob tool instead of the Agent tool, to find the match more quickly - If you are searching for a specific class definition like "class Foo", use the Glob tool instead, to find the match more quickly - If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Agent tool, to find the match more quickly +- Other tasks that are not related to the agent descriptions above + Usage notes: 1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses @@ -14,3 +21,40 @@ Usage notes: 3. Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. 4. The agent's outputs should generally be trusted 5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent +6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. + +Example usage: + + +"code-reviewer": use this agent after you are done writing a signficant piece of code +"greeting-responder": use this agent when to respond to user greetings with a friendly joke + + + +user: "Please write a function that checks if a number is prime" +assistant: Sure let me write a function that checks if a number is prime +assistant: First let me use the Write tool to write a function that checks if a number is prime +assistant: I'm going to use the Write tool to write the following code: + +function isPrime(n) { + if (n <= 1) return false + for (let i = 2; i * i <= n; i++) { + if (n % i === 0) return false + } + return true +} + + +Since a signficant piece of code was written and the task was completed, now use the code-reviewer agent to review the code + +assistant: Now let me use the code-reviewer agent to review the code +assistant: Uses the Task tool to launch the with the code-reviewer agent + + + +user: "Hello" + +Since the user is greeting, use the greeting-responder agent to respond with a friendly joke + +assistant: "I'm going to use the Task tool to launch the with the greeting-responder agent" + diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index f44322ed8..503365a24 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -25,8 +25,8 @@ export namespace Tool { } export function define( - input: Info, - ): Info { - return input + input: Info | (() => Promise>), + ): () => Promise> { + return input instanceof Function ? input : async () => input } } diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index d5149cf39..3336dbf4e 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -49,10 +49,11 @@ export namespace Filesystem { const glob = new Bun.Glob(pattern) for await (const match of glob.scan({ cwd: current, + absolute: true, onlyFiles: true, dot: true, })) { - result.push(join(current, match)) + result.push(match) } } catch { // Skip invalid glob patterns diff --git a/packages/opencode/test/tool/tool.test.ts b/packages/opencode/test/tool/tool.test.ts index 88325029c..728786bc4 100644 --- a/packages/opencode/test/tool/tool.test.ts +++ b/packages/opencode/test/tool/tool.test.ts @@ -9,10 +9,13 @@ const ctx = { abort: AbortSignal.any([]), metadata: () => {}, } +const glob = await GlobTool() +const list = await ListTool() + describe("tool.glob", () => { test("truncate", async () => { await App.provide({ cwd: process.cwd() }, async () => { - let result = await GlobTool.execute( + let result = await glob.execute( { pattern: "../../node_modules/**/*", path: undefined, @@ -24,7 +27,7 @@ describe("tool.glob", () => { }) test("basic", async () => { await App.provide({ cwd: process.cwd() }, async () => { - let result = await GlobTool.execute( + let result = await glob.execute( { pattern: "*.json", path: undefined, @@ -42,7 +45,7 @@ describe("tool.glob", () => { describe("tool.ls", () => { test("basic", async () => { const result = await App.provide({ cwd: process.cwd() }, async () => { - return await ListTool.execute({ path: "./example", ignore: [".git"] }, ctx) + return await list.execute({ path: "./example", ignore: [".git"] }, ctx) }) expect(result.output).toMatchSnapshot() }) diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 4f2fc98ef..e3f6a493e 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -66,6 +66,7 @@ export default defineConfig({ "docs/ide", "docs/share", "docs/modes", + "docs/agents", "docs/rules", "docs/config", "docs/models", diff --git a/packages/web/src/content/docs/docs/agents.mdx b/packages/web/src/content/docs/docs/agents.mdx new file mode 100644 index 000000000..b7a90d68b --- /dev/null +++ b/packages/web/src/content/docs/docs/agents.mdx @@ -0,0 +1,158 @@ +--- +title: Agents +description: Configure and use specialized agents in opencode. +--- + +Agents are specialized AI assistants that can be configured for specific tasks and workflows. They allow you to create focused tools with custom prompts, models, and tool access. + +## Built-in Agents + +opencode comes with a built-in `general` agent: + +- **general** - General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. Use this when searching for keywords or files and you're not confident you'll find the right match in the first few tries. + +## Configuration + +Agents can be configured in your `opencode.json` config file or as markdown files. + +### JSON Configuration + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "code-reviewer": { + "description": "Reviews code for best practices and potential issues", + "model": "anthropic/claude-sonnet-4-20250514", + "prompt": "You are a code reviewer. Focus on security, performance, and maintainability.", + "tools": { + "write": false, + "edit": false + } + }, + "test-writer": { + "description": "Specialized agent for writing comprehensive tests", + "prompt": "You are a test writing specialist. Write thorough, maintainable tests.", + "tools": { + "bash": true, + "read": true, + "write": true + } + } + } +} +``` + +### Markdown Configuration + +You can also define agents using markdown files. Place them in: + +- Global: `~/.config/opencode/agent/` +- Project: `.opencode/agent/` + +```markdown title="~/.config/opencode/agent/code-reviewer.md" +--- +description: Reviews code for best practices and potential issues +model: anthropic/claude-sonnet-4-20250514 +tools: + write: false + edit: false +--- + +You are a code reviewer with expertise in security, performance, and maintainability. + +Focus on: + +- Security vulnerabilities +- Performance bottlenecks +- Code maintainability +- Best practices adherence +``` + +## Agent Properties + +### Required + +- **description** - Brief description of what the agent does and when to use it + +### Optional + +- **model** - Specific model to use (defaults to your configured model) +- **prompt** - Custom system prompt for the agent +- **tools** - Object specifying which tools the agent can access (true/false for each tool) +- **disable** - Set to true to disable the agent + +## Tool Access + +By default, agents inherit the same tool access as the main assistant. You can restrict or enable specific tools: + +```json +{ + "agent": { + "readonly-agent": { + "description": "Read-only agent for analysis", + "tools": { + "write": false, + "edit": false, + "bash": false + } + } + } +} +``` + +Common tools you might want to control: + +- `write` - Create new files +- `edit` - Modify existing files +- `bash` - Execute shell commands +- `read` - Read files +- `glob` - Search for files +- `grep` - Search file contents + +## Using Agents + +Agents are automatically available through the Task tool when configured. The main assistant will use them for specialized tasks based on their descriptions. + +## Best Practices + +1. **Clear descriptions** - Write specific descriptions that help the main assistant know when to use each agent +2. **Focused prompts** - Keep agent prompts focused on their specific role +3. **Appropriate tool access** - Only give agents the tools they need for their tasks +4. **Consistent naming** - Use descriptive, consistent names for your agents +5. **Project-specific agents** - Use `.opencode/agent/` for project-specific workflows + +## Examples + +### Documentation Agent + +```json +{ + "agent": { + "docs-writer": { + "description": "Writes and maintains project documentation", + "prompt": "You are a technical writer. Create clear, comprehensive documentation.", + "tools": { + "bash": false + } + } + } +} +``` + +### Security Auditor + +```json +{ + "agent": { + "security-auditor": { + "description": "Performs security audits and identifies vulnerabilities", + "prompt": "You are a security expert. Focus on identifying potential security issues.", + "tools": { + "write": false, + "edit": false + } + } + } +} +``` diff --git a/packages/web/src/content/docs/docs/config.mdx b/packages/web/src/content/docs/docs/config.mdx index 77026c417..8b9df9ce1 100644 --- a/packages/web/src/content/docs/docs/config.mdx +++ b/packages/web/src/content/docs/docs/config.mdx @@ -50,9 +50,9 @@ opencode comes with two built-in modes: _build_, the default with all tools enab { "$schema": "https://opencode.ai/config.json", "mode": { - "build": { }, - "plan": { }, - "my-custom-mode": { } + "build": {}, + "plan": {}, + "my-custom-mode": {} } } ``` @@ -181,6 +181,31 @@ about rules here](/docs/rules). --- +### Agents + +You can configure specialized agents for specific tasks through the `agent` option. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "code-reviewer": { + "description": "Reviews code for best practices and potential issues", + "model": "anthropic/claude-sonnet-4-20250514", + "prompt": "You are a code reviewer. Focus on security, performance, and maintainability.", + "tools": { + "write": false, + "edit": false + } + } + } +} +``` + +You can also define agents using markdown files in `~/.config/opencode/agent/` or `.opencode/agent/`. [Learn more here](/docs/agents). + +--- + ### Disabled providers You can disable providers that are loaded automatically through the `disabled_providers` option. This is useful when you want to prevent certain providers from being loaded even if their credentials are available.