summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAiden Cline <[email protected]>2026-03-24 13:15:23 -0500
committerGitHub <[email protected]>2026-03-24 18:15:23 +0000
commit5e684c6e80d30a77ba02db013c61b8ecfe420f7f (patch)
tree41e2d94059c672fc15eff377828e5ec1bd298e56 /packages
parent2c1d8a90d567d65ac044b2feaf2ee886318247ec (diff)
downloadopencode-5e684c6e80d30a77ba02db013c61b8ecfe420f7f.tar.gz
opencode-5e684c6e80d30a77ba02db013c61b8ecfe420f7f.zip
chore: effectify agent.ts (#18971)
Co-authored-by: Kit Langton <[email protected]>
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/agent/agent.ts609
1 files changed, 340 insertions, 269 deletions
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index 30d098614..72b286964 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -3,7 +3,6 @@ import z from "zod"
import { Provider } from "../provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
-import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncate"
import { Auth } from "../auth"
@@ -20,6 +19,9 @@ import { Global } from "@/global"
import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"
+import { Effect, ServiceMap, Layer } from "effect"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
export namespace Agent {
export const Info = z
@@ -49,295 +51,364 @@ export namespace Agent {
})
export type Info = z.infer<typeof Info>
- const state = Instance.state(async () => {
- const cfg = await Config.get()
+ export interface Interface {
+ readonly get: (agent: string) => Effect.Effect<Agent.Info>
+ readonly list: () => Effect.Effect<Agent.Info[]>
+ readonly defaultAgent: () => Effect.Effect<string>
+ readonly generate: (input: {
+ description: string
+ model?: { providerID: ProviderID; modelID: ModelID }
+ }) => Effect.Effect<{
+ identifier: string
+ whenToUse: string
+ systemPrompt: string
+ }>
+ }
- const skillDirs = await Skill.dirs()
- const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
- 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 ?? {})
+ type State = Omit<Interface, "generate">
- const result: 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",
- external_directory: {
- [path.join(Global.Path.data, "plans", "*")]: "allow",
- },
- edit: {
- "*": "deny",
- [path.join(".opencode", "plans", "*.md")]: "allow",
- [path.relative(Instance.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({
- todoread: "deny",
- 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",
- codesearch: "allow",
- read: "allow",
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Agent") {}
+
+ export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const config = () => Effect.promise(() => Config.get())
+ const auth = yield* Auth.Service
+
+ const state = yield* InstanceState.make<State>(
+ Effect.fn("Agent.state")(function* (ctx) {
+ const cfg = yield* config()
+ const skillDirs = yield* Effect.promise(() => Skill.dirs())
+ const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
+
+ const defaults = Permission.fromConfig({
+ "*": "allow",
+ doom_loop: "ask",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
- }),
- 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,
- },
- }
+ 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",
+ },
+ })
- for (const [key, value] of Object.entries(cfg.agent ?? {})) {
- if (value.disable) {
- delete result[key]
- continue
- }
- let item = result[key]
- if (!item)
- item = result[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 ?? {}))
- }
+ const user = Permission.fromConfig(cfg.permission ?? {})
- // Ensure Truncate.GLOB is allowed unless explicitly configured
- for (const name in result) {
- const agent = result[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
+ 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",
+ external_directory: {
+ [path.join(Global.Path.data, "plans", "*")]: "allow",
+ },
+ edit: {
+ "*": "deny",
+ [path.join(".opencode", "plans", "*.md")]: "allow",
+ [path.relative(Instance.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({
+ todoread: "deny",
+ 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",
+ codesearch: "allow",
+ read: "allow",
+ external_directory: {
+ "*": "ask",
+ ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
+ },
+ }),
+ 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,
+ },
+ }
- result[name].permission = Permission.merge(
- result[name].permission,
- Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
- )
- }
+ 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 ?? {}))
+ }
- return result
- })
+ // 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
- export async function get(agent: string) {
- return state().then((x) => x[agent])
- }
+ agents[name].permission = Permission.merge(
+ agents[name].permission,
+ Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
+ )
+ }
- export async function list() {
- const cfg = await Config.get()
- return pipe(
- await state(),
- values(),
- sortBy(
- [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
- [(x) => x.name, "asc"],
- ),
- )
- }
+ const get = Effect.fnUntraced(function* (agent: string) {
+ return agents[agent]
+ })
- export async function defaultAgent() {
- const cfg = await Config.get()
- const agents = await state()
+ const list = Effect.fnUntraced(function* () {
+ const cfg = yield* config()
+ return pipe(
+ agents,
+ values(),
+ sortBy(
+ [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
+ [(x) => x.name, "asc"],
+ ),
+ )
+ })
- if (cfg.default_agent) {
- const agent = agents[cfg.default_agent]
- if (!agent) throw new Error(`default agent "${cfg.default_agent}" not found`)
- if (agent.mode === "subagent") throw new Error(`default agent "${cfg.default_agent}" is a subagent`)
- if (agent.hidden === true) throw new Error(`default agent "${cfg.default_agent}" is hidden`)
- return agent.name
- }
+ const defaultAgent = Effect.fnUntraced(function* () {
+ const c = yield* config()
+ 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.name
+ }
+ 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.name
+ })
- const primaryVisible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
- if (!primaryVisible) throw new Error("no primary visible agent found")
- return primaryVisible.name
- }
+ return {
+ get,
+ list,
+ defaultAgent,
+ } satisfies State
+ }),
+ )
- export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) {
- const cfg = await Config.get()
- const defaultModel = input.model ?? (await Provider.defaultModel())
- const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID)
- const language = await Provider.getLanguage(model)
+ 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())
+ }),
+ 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: ProviderID; modelID: ModelID }
+ }) {
+ const cfg = yield* config()
+ const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel()))
+ const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID))
+ const language = yield* Effect.promise(() => Provider.getLanguage(resolved))
- const system = [PROMPT_GENERATE]
- await Plugin.trigger("experimental.chat.system.transform", { model }, { system })
- const existing = await list()
+ const system = [PROMPT_GENERATE]
+ yield* Effect.promise(() =>
+ Plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }),
+ )
+ const existing = yield* InstanceState.useEffect(state, (s) => s.list())
- const params = {
- experimental_telemetry: {
- isEnabled: cfg.experimental?.openTelemetry,
- metadata: {
- userId: cfg.username ?? "unknown",
- },
- },
- temperature: 0.3,
- messages: [
- ...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: z.object({
- identifier: z.string(),
- whenToUse: z.string(),
- systemPrompt: z.string(),
- }),
- } satisfies Parameters<typeof generateObject>[0]
+ const params = {
+ experimental_telemetry: {
+ isEnabled: cfg.experimental?.openTelemetry,
+ metadata: {
+ userId: cfg.username ?? "unknown",
+ },
+ },
+ temperature: 0.3,
+ messages: [
+ ...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: z.object({
+ identifier: z.string(),
+ whenToUse: z.string(),
+ systemPrompt: z.string(),
+ }),
+ } satisfies Parameters<typeof generateObject>[0]
- // TODO: clean this up so provider specific logic doesnt bleed over
- if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
- const result = streamObject({
- ...params,
- providerOptions: ProviderTransform.providerOptions(model, {
- store: false,
+ // TODO: clean this up so provider specific logic doesnt bleed over
+ const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
+ if (model.providerID === "openai" && authInfo?.type === "oauth") {
+ return yield* Effect.promise(async () => {
+ const result = streamObject({
+ ...params,
+ providerOptions: ProviderTransform.providerOptions(resolved, {
+ 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))
}),
- onError: () => {},
})
- for await (const part of result.fullStream) {
- if (part.type === "error") throw part.error
- }
- return result.object
- }
+ }),
+ )
+
+ export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
- const result = await generateObject(params)
- return result.object
+ const runPromise = makeRunPromise(Service, defaultLayer)
+
+ export async function get(agent: string) {
+ return runPromise((svc) => svc.get(agent))
+ }
+
+ export async function list() {
+ return runPromise((svc) => svc.list())
+ }
+
+ export async function defaultAgent() {
+ return runPromise((svc) => svc.defaultAgent())
+ }
+
+ export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) {
+ return runPromise((svc) => svc.generate(input))
}
}