| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459 |
- import { LayerNode } from "@opencode-ai/core/effect/layer-node"
- import { PermissionV1 } from "@opencode-ai/core/v1/permission"
- import { Config } from "@/config/config"
- import { serviceUse } from "@opencode-ai/core/effect/service-use"
- import { Provider } from "@/provider/provider"
- import { generateObject, streamObject, type ModelMessage } from "ai"
- import { Truncate } from "@/tool/truncate"
- import { Auth } from "../auth"
- import { ProviderTransform } from "@/provider/transform"
- import PROMPT_GENERATE from "./generate.txt"
- import PROMPT_COMPACTION from "./prompt/compaction.txt"
- import PROMPT_EXPLORE from "./prompt/explore.txt"
- import PROMPT_SUMMARY from "./prompt/summary.txt"
- import PROMPT_TITLE from "./prompt/title.txt"
- import { Permission } from "@/permission"
- import { mergeDeep, pipe, sortBy, values } from "remeda"
- import { Global } from "@opencode-ai/core/global"
- import path from "path"
- import { Plugin } from "@/plugin"
- import { Skill } from "../skill"
- import { Effect, Context, Layer, Schema } from "effect"
- import { InstanceState } from "@/effect/instance-state"
- import * as Option from "effect/Option"
- import * as OtelTracer from "@effect/opentelemetry/Tracer"
- import { AbsolutePath, type DeepMutable } from "@opencode-ai/core/schema"
- import { ProviderV2 } from "@opencode-ai/core/provider"
- import { ModelV2 } from "@opencode-ai/core/model"
- import { LocationServiceMap } from "@opencode-ai/core/location-layer"
- import { PluginBoot } from "@opencode-ai/core/plugin/boot"
- import { Reference } from "@opencode-ai/core/reference"
- import { Location } from "@opencode-ai/core/location"
- export const Info = Schema.Struct({
- name: Schema.String,
- description: Schema.optional(Schema.String),
- mode: Schema.Literals(["subagent", "primary", "all"]),
- native: Schema.optional(Schema.Boolean),
- hidden: Schema.optional(Schema.Boolean),
- topP: Schema.optional(Schema.Finite),
- temperature: Schema.optional(Schema.Finite),
- color: Schema.optional(Schema.String),
- permission: PermissionV1.Ruleset,
- model: Schema.optional(
- Schema.Struct({
- modelID: ModelV2.ID,
- providerID: ProviderV2.ID,
- }),
- ),
- variant: Schema.optional(Schema.String),
- prompt: Schema.optional(Schema.String),
- options: Schema.Record(Schema.String, Schema.Unknown),
- steps: Schema.optional(Schema.Finite),
- }).annotate({ identifier: "Agent" })
- export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
- const GeneratedAgent = Schema.Struct({
- identifier: Schema.String,
- whenToUse: Schema.String,
- systemPrompt: Schema.String,
- })
- export interface Interface {
- readonly get: (agent: string) => Effect.Effect<Info>
- readonly list: () => Effect.Effect<Info[]>
- readonly defaultInfo: () => Effect.Effect<Info>
- readonly defaultAgent: () => Effect.Effect<string>
- readonly generate: (input: {
- description: string
- model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
- }) => Effect.Effect<
- {
- identifier: string
- whenToUse: string
- systemPrompt: string
- },
- Provider.DefaultModelError
- >
- }
- type State = Omit<Interface, "generate">
- export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
- export const use = serviceUse(Service)
- export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const config = yield* Config.Service
- const auth = yield* Auth.Service
- const plugin = yield* Plugin.Service
- const skill = yield* Skill.Service
- const provider = yield* Provider.Service
- const locations = yield* LocationServiceMap
- const state = yield* InstanceState.make<State>(
- Effect.fn("Agent.state")(function* (ctx) {
- const cfg = yield* config.get()
- const skillDirs = yield* skill.dirs()
- const referenceDirs = yield* Effect.gen(function* () {
- yield* (yield* PluginBoot.Service).wait()
- return (yield* (yield* Reference.Service).list()).map((reference) => reference.path)
- }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
- const whitelistedDirs = [
- Truncate.GLOB,
- path.join(Global.Path.tmp, "*"),
- ...skillDirs.map((dir) => path.join(dir, "*")),
- ...referenceDirs.map((dir) => path.join(dir, "*")),
- ]
- const readonlyExternalDirectory = {
- "*": "ask",
- ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
- } satisfies Record<string, "allow" | "ask" | "deny">
- const defaults = Permission.fromConfig({
- "*": "allow",
- doom_loop: "ask",
- external_directory: {
- "*": "ask",
- ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
- },
- question: "deny",
- plan_enter: "deny",
- plan_exit: "deny",
- // mirrors github.com/github/gitignore Node.gitignore pattern for .env files
- read: {
- "*": "allow",
- "*.env": "ask",
- "*.env.*": "ask",
- "*.env.example": "allow",
- },
- })
- const user = Permission.fromConfig(cfg.permission ?? {})
- const agents: Record<string, Info> = {
- build: {
- name: "build",
- description: "The default agent. Executes tools based on configured permissions.",
- options: {},
- permission: Permission.merge(
- defaults,
- Permission.fromConfig({
- question: "allow",
- plan_enter: "allow",
- }),
- user,
- ),
- mode: "primary",
- native: true,
- },
- plan: {
- name: "plan",
- description: "Plan mode. Disallows all edit tools.",
- options: {},
- permission: Permission.merge(
- defaults,
- Permission.fromConfig({
- question: "allow",
- plan_exit: "allow",
- task: {
- general: "deny",
- },
- external_directory: {
- [path.join(Global.Path.data, "plans", "*")]: "allow",
- },
- edit: {
- "*": "deny",
- [path.join(".opencode", "plans", "*.md")]: "allow",
- [path.relative(ctx.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
- },
- }),
- user,
- ),
- mode: "primary",
- native: true,
- },
- general: {
- name: "general",
- description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
- permission: Permission.merge(
- defaults,
- Permission.fromConfig({
- todowrite: "deny",
- }),
- user,
- ),
- options: {},
- mode: "subagent",
- native: true,
- },
- explore: {
- name: "explore",
- permission: Permission.merge(
- defaults,
- Permission.fromConfig({
- "*": "deny",
- grep: "allow",
- glob: "allow",
- list: "allow",
- bash: "allow",
- webfetch: "allow",
- websearch: "allow",
- read: "allow",
- external_directory: readonlyExternalDirectory,
- }),
- user,
- ),
- description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
- prompt: PROMPT_EXPLORE,
- options: {},
- mode: "subagent",
- native: true,
- },
- compaction: {
- name: "compaction",
- mode: "primary",
- native: true,
- hidden: true,
- prompt: PROMPT_COMPACTION,
- permission: Permission.merge(
- defaults,
- Permission.fromConfig({
- "*": "deny",
- }),
- user,
- ),
- options: {},
- },
- title: {
- name: "title",
- mode: "primary",
- options: {},
- native: true,
- hidden: true,
- temperature: 0.5,
- permission: Permission.merge(
- defaults,
- Permission.fromConfig({
- "*": "deny",
- }),
- user,
- ),
- prompt: PROMPT_TITLE,
- },
- summary: {
- name: "summary",
- mode: "primary",
- options: {},
- native: true,
- hidden: true,
- permission: Permission.merge(
- defaults,
- Permission.fromConfig({
- "*": "deny",
- }),
- user,
- ),
- prompt: PROMPT_SUMMARY,
- },
- }
- for (const [key, value] of Object.entries(cfg.agent ?? {})) {
- if (value.disable) {
- delete agents[key]
- continue
- }
- let item = agents[key]
- if (!item)
- item = agents[key] = {
- name: key,
- mode: "all",
- permission: Permission.merge(defaults, user),
- options: {},
- native: false,
- }
- if (value.model) item.model = Provider.parseModel(value.model)
- item.variant = value.variant ?? item.variant
- item.prompt = value.prompt ?? item.prompt
- item.description = value.description ?? item.description
- item.temperature = value.temperature ?? item.temperature
- item.topP = value.top_p ?? item.topP
- item.mode = value.mode ?? item.mode
- item.color = value.color ?? item.color
- item.hidden = value.hidden ?? item.hidden
- item.name = value.name ?? item.name
- item.steps = value.steps ?? item.steps
- item.options = mergeDeep(item.options, value.options ?? {})
- item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
- }
- // Ensure Truncate.GLOB is allowed unless explicitly configured
- for (const name in agents) {
- const agent = agents[name]
- const explicit = agent.permission.some((r) => {
- if (r.permission !== "external_directory") return false
- if (r.action !== "deny") return false
- return r.pattern === Truncate.GLOB
- })
- if (explicit) continue
- agents[name].permission = Permission.merge(
- agents[name].permission,
- Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
- )
- }
- const get = Effect.fnUntraced(function* (agent: string) {
- return agents[agent]
- })
- const list = Effect.fnUntraced(function* () {
- const cfg = yield* config.get()
- return pipe(
- agents,
- values(),
- sortBy(
- [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
- [(x) => x.name, "asc"],
- ),
- )
- })
- const defaultInfo = Effect.fnUntraced(function* () {
- const c = yield* config.get()
- if (c.default_agent) {
- const agent = agents[c.default_agent]
- if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
- if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
- if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
- return agent
- }
- const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
- if (!visible) throw new Error("no primary visible agent found")
- return visible
- })
- const defaultAgent = Effect.fnUntraced(function* () {
- return (yield* defaultInfo()).name
- })
- return {
- get,
- list,
- defaultInfo,
- defaultAgent,
- } satisfies State
- }),
- )
- return Service.of({
- get: Effect.fn("Agent.get")(function* (agent: string) {
- return yield* InstanceState.useEffect(state, (s) => s.get(agent))
- }),
- list: Effect.fn("Agent.list")(function* () {
- return yield* InstanceState.useEffect(state, (s) => s.list())
- }),
- defaultInfo: Effect.fn("Agent.defaultInfo")(function* () {
- return yield* InstanceState.useEffect(state, (s) => s.defaultInfo())
- }),
- defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
- return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
- }),
- generate: Effect.fn("Agent.generate")(function* (input: {
- description: string
- model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
- }) {
- const cfg = yield* config.get()
- const model = input.model ?? (yield* provider.defaultModel())
- const resolved = yield* provider.getModel(model.providerID, model.modelID)
- const language = yield* provider.getLanguage(resolved)
- const tracer = cfg.experimental?.openTelemetry
- ? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
- : undefined
- const system = [PROMPT_GENERATE]
- yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
- const existing = yield* InstanceState.useEffect(state, (s) => s.list())
- // TODO: clean this up so provider specific logic doesnt bleed over
- const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
- const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
- const params = {
- experimental_telemetry: {
- isEnabled: cfg.experimental?.openTelemetry,
- tracer,
- metadata: {
- userId: cfg.username ?? "unknown",
- },
- },
- temperature: 0.3,
- messages: [
- ...(isOpenaiOauth
- ? []
- : 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: language,
- schema: Object.assign(
- Schema.toStandardSchemaV1(GeneratedAgent),
- Schema.toStandardJSONSchemaV1(GeneratedAgent),
- ),
- } satisfies Parameters<typeof generateObject>[0]
- if (isOpenaiOauth) {
- return yield* Effect.promise(async () => {
- const result = streamObject({
- ...params,
- providerOptions: ProviderTransform.providerOptions(resolved, {
- instructions: system.join("\n"),
- store: false,
- }),
- onError: () => {},
- })
- for await (const part of result.fullStream) {
- if (part.type === "error") throw part.error
- }
- return result.object
- })
- }
- return yield* Effect.promise(() => generateObject(params).then((r) => r.object))
- }),
- })
- }),
- )
- export const defaultLayer = layer.pipe(
- Layer.provide(Plugin.defaultLayer),
- Layer.provide(Provider.defaultLayer),
- Layer.provide(Auth.defaultLayer),
- Layer.provide(Config.defaultLayer),
- Layer.provide(Skill.defaultLayer),
- Layer.provide(LocationServiceMap.layer),
- )
- const locationServiceMapNode = LayerNode.make(LocationServiceMap.layer, [])
- export const node = LayerNode.make(layer, [
- Config.node,
- Auth.node,
- Plugin.node,
- Skill.node,
- Provider.node,
- locationServiceMapNode,
- ])
- export * as Agent from "./agent"
|