agent.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  2. import { PermissionV1 } from "@opencode-ai/core/v1/permission"
  3. import { Config } from "@/config/config"
  4. import { serviceUse } from "@opencode-ai/core/effect/service-use"
  5. import { Provider } from "@/provider/provider"
  6. import { generateObject, streamObject, type ModelMessage } from "ai"
  7. import { Truncate } from "@/tool/truncate"
  8. import { Auth } from "../auth"
  9. import { ProviderTransform } from "@/provider/transform"
  10. import PROMPT_GENERATE from "./generate.txt"
  11. import PROMPT_COMPACTION from "./prompt/compaction.txt"
  12. import PROMPT_EXPLORE from "./prompt/explore.txt"
  13. import PROMPT_SUMMARY from "./prompt/summary.txt"
  14. import PROMPT_TITLE from "./prompt/title.txt"
  15. import { Permission } from "@/permission"
  16. import { mergeDeep, pipe, sortBy, values } from "remeda"
  17. import { Global } from "@opencode-ai/core/global"
  18. import path from "path"
  19. import { Plugin } from "@/plugin"
  20. import { Skill } from "../skill"
  21. import { Effect, Context, Layer, Schema } from "effect"
  22. import { InstanceState } from "@/effect/instance-state"
  23. import * as Option from "effect/Option"
  24. import * as OtelTracer from "@effect/opentelemetry/Tracer"
  25. import { AbsolutePath, type DeepMutable } from "@opencode-ai/core/schema"
  26. import { ProviderV2 } from "@opencode-ai/core/provider"
  27. import { ModelV2 } from "@opencode-ai/core/model"
  28. import { LocationServiceMap } from "@opencode-ai/core/location-layer"
  29. import { PluginBoot } from "@opencode-ai/core/plugin/boot"
  30. import { Reference } from "@opencode-ai/core/reference"
  31. import { Location } from "@opencode-ai/core/location"
  32. export const Info = Schema.Struct({
  33. name: Schema.String,
  34. description: Schema.optional(Schema.String),
  35. mode: Schema.Literals(["subagent", "primary", "all"]),
  36. native: Schema.optional(Schema.Boolean),
  37. hidden: Schema.optional(Schema.Boolean),
  38. topP: Schema.optional(Schema.Finite),
  39. temperature: Schema.optional(Schema.Finite),
  40. color: Schema.optional(Schema.String),
  41. permission: PermissionV1.Ruleset,
  42. model: Schema.optional(
  43. Schema.Struct({
  44. modelID: ModelV2.ID,
  45. providerID: ProviderV2.ID,
  46. }),
  47. ),
  48. variant: Schema.optional(Schema.String),
  49. prompt: Schema.optional(Schema.String),
  50. options: Schema.Record(Schema.String, Schema.Unknown),
  51. steps: Schema.optional(Schema.Finite),
  52. }).annotate({ identifier: "Agent" })
  53. export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
  54. const GeneratedAgent = Schema.Struct({
  55. identifier: Schema.String,
  56. whenToUse: Schema.String,
  57. systemPrompt: Schema.String,
  58. })
  59. export interface Interface {
  60. readonly get: (agent: string) => Effect.Effect<Info>
  61. readonly list: () => Effect.Effect<Info[]>
  62. readonly defaultInfo: () => Effect.Effect<Info>
  63. readonly defaultAgent: () => Effect.Effect<string>
  64. readonly generate: (input: {
  65. description: string
  66. model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
  67. }) => Effect.Effect<
  68. {
  69. identifier: string
  70. whenToUse: string
  71. systemPrompt: string
  72. },
  73. Provider.DefaultModelError
  74. >
  75. }
  76. type State = Omit<Interface, "generate">
  77. export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
  78. export const use = serviceUse(Service)
  79. export const layer = Layer.effect(
  80. Service,
  81. Effect.gen(function* () {
  82. const config = yield* Config.Service
  83. const auth = yield* Auth.Service
  84. const plugin = yield* Plugin.Service
  85. const skill = yield* Skill.Service
  86. const provider = yield* Provider.Service
  87. const locations = yield* LocationServiceMap
  88. const state = yield* InstanceState.make<State>(
  89. Effect.fn("Agent.state")(function* (ctx) {
  90. const cfg = yield* config.get()
  91. const skillDirs = yield* skill.dirs()
  92. const referenceDirs = yield* Effect.gen(function* () {
  93. yield* (yield* PluginBoot.Service).wait()
  94. return (yield* (yield* Reference.Service).list()).map((reference) => reference.path)
  95. }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
  96. const whitelistedDirs = [
  97. Truncate.GLOB,
  98. path.join(Global.Path.tmp, "*"),
  99. ...skillDirs.map((dir) => path.join(dir, "*")),
  100. ...referenceDirs.map((dir) => path.join(dir, "*")),
  101. ]
  102. const readonlyExternalDirectory = {
  103. "*": "ask",
  104. ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
  105. } satisfies Record<string, "allow" | "ask" | "deny">
  106. const defaults = Permission.fromConfig({
  107. "*": "allow",
  108. doom_loop: "ask",
  109. external_directory: {
  110. "*": "ask",
  111. ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
  112. },
  113. question: "deny",
  114. plan_enter: "deny",
  115. plan_exit: "deny",
  116. // mirrors github.com/github/gitignore Node.gitignore pattern for .env files
  117. read: {
  118. "*": "allow",
  119. "*.env": "ask",
  120. "*.env.*": "ask",
  121. "*.env.example": "allow",
  122. },
  123. })
  124. const user = Permission.fromConfig(cfg.permission ?? {})
  125. const agents: Record<string, Info> = {
  126. build: {
  127. name: "build",
  128. description: "The default agent. Executes tools based on configured permissions.",
  129. options: {},
  130. permission: Permission.merge(
  131. defaults,
  132. Permission.fromConfig({
  133. question: "allow",
  134. plan_enter: "allow",
  135. }),
  136. user,
  137. ),
  138. mode: "primary",
  139. native: true,
  140. },
  141. plan: {
  142. name: "plan",
  143. description: "Plan mode. Disallows all edit tools.",
  144. options: {},
  145. permission: Permission.merge(
  146. defaults,
  147. Permission.fromConfig({
  148. question: "allow",
  149. plan_exit: "allow",
  150. task: {
  151. general: "deny",
  152. },
  153. external_directory: {
  154. [path.join(Global.Path.data, "plans", "*")]: "allow",
  155. },
  156. edit: {
  157. "*": "deny",
  158. [path.join(".opencode", "plans", "*.md")]: "allow",
  159. [path.relative(ctx.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
  160. },
  161. }),
  162. user,
  163. ),
  164. mode: "primary",
  165. native: true,
  166. },
  167. general: {
  168. name: "general",
  169. description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
  170. permission: Permission.merge(
  171. defaults,
  172. Permission.fromConfig({
  173. todowrite: "deny",
  174. }),
  175. user,
  176. ),
  177. options: {},
  178. mode: "subagent",
  179. native: true,
  180. },
  181. explore: {
  182. name: "explore",
  183. permission: Permission.merge(
  184. defaults,
  185. Permission.fromConfig({
  186. "*": "deny",
  187. grep: "allow",
  188. glob: "allow",
  189. list: "allow",
  190. bash: "allow",
  191. webfetch: "allow",
  192. websearch: "allow",
  193. read: "allow",
  194. external_directory: readonlyExternalDirectory,
  195. }),
  196. user,
  197. ),
  198. 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.`,
  199. prompt: PROMPT_EXPLORE,
  200. options: {},
  201. mode: "subagent",
  202. native: true,
  203. },
  204. compaction: {
  205. name: "compaction",
  206. mode: "primary",
  207. native: true,
  208. hidden: true,
  209. prompt: PROMPT_COMPACTION,
  210. permission: Permission.merge(
  211. defaults,
  212. Permission.fromConfig({
  213. "*": "deny",
  214. }),
  215. user,
  216. ),
  217. options: {},
  218. },
  219. title: {
  220. name: "title",
  221. mode: "primary",
  222. options: {},
  223. native: true,
  224. hidden: true,
  225. temperature: 0.5,
  226. permission: Permission.merge(
  227. defaults,
  228. Permission.fromConfig({
  229. "*": "deny",
  230. }),
  231. user,
  232. ),
  233. prompt: PROMPT_TITLE,
  234. },
  235. summary: {
  236. name: "summary",
  237. mode: "primary",
  238. options: {},
  239. native: true,
  240. hidden: true,
  241. permission: Permission.merge(
  242. defaults,
  243. Permission.fromConfig({
  244. "*": "deny",
  245. }),
  246. user,
  247. ),
  248. prompt: PROMPT_SUMMARY,
  249. },
  250. }
  251. for (const [key, value] of Object.entries(cfg.agent ?? {})) {
  252. if (value.disable) {
  253. delete agents[key]
  254. continue
  255. }
  256. let item = agents[key]
  257. if (!item)
  258. item = agents[key] = {
  259. name: key,
  260. mode: "all",
  261. permission: Permission.merge(defaults, user),
  262. options: {},
  263. native: false,
  264. }
  265. if (value.model) item.model = Provider.parseModel(value.model)
  266. item.variant = value.variant ?? item.variant
  267. item.prompt = value.prompt ?? item.prompt
  268. item.description = value.description ?? item.description
  269. item.temperature = value.temperature ?? item.temperature
  270. item.topP = value.top_p ?? item.topP
  271. item.mode = value.mode ?? item.mode
  272. item.color = value.color ?? item.color
  273. item.hidden = value.hidden ?? item.hidden
  274. item.name = value.name ?? item.name
  275. item.steps = value.steps ?? item.steps
  276. item.options = mergeDeep(item.options, value.options ?? {})
  277. item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
  278. }
  279. // Ensure Truncate.GLOB is allowed unless explicitly configured
  280. for (const name in agents) {
  281. const agent = agents[name]
  282. const explicit = agent.permission.some((r) => {
  283. if (r.permission !== "external_directory") return false
  284. if (r.action !== "deny") return false
  285. return r.pattern === Truncate.GLOB
  286. })
  287. if (explicit) continue
  288. agents[name].permission = Permission.merge(
  289. agents[name].permission,
  290. Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
  291. )
  292. }
  293. const get = Effect.fnUntraced(function* (agent: string) {
  294. return agents[agent]
  295. })
  296. const list = Effect.fnUntraced(function* () {
  297. const cfg = yield* config.get()
  298. return pipe(
  299. agents,
  300. values(),
  301. sortBy(
  302. [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
  303. [(x) => x.name, "asc"],
  304. ),
  305. )
  306. })
  307. const defaultInfo = Effect.fnUntraced(function* () {
  308. const c = yield* config.get()
  309. if (c.default_agent) {
  310. const agent = agents[c.default_agent]
  311. if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
  312. if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
  313. if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
  314. return agent
  315. }
  316. const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
  317. if (!visible) throw new Error("no primary visible agent found")
  318. return visible
  319. })
  320. const defaultAgent = Effect.fnUntraced(function* () {
  321. return (yield* defaultInfo()).name
  322. })
  323. return {
  324. get,
  325. list,
  326. defaultInfo,
  327. defaultAgent,
  328. } satisfies State
  329. }),
  330. )
  331. return Service.of({
  332. get: Effect.fn("Agent.get")(function* (agent: string) {
  333. return yield* InstanceState.useEffect(state, (s) => s.get(agent))
  334. }),
  335. list: Effect.fn("Agent.list")(function* () {
  336. return yield* InstanceState.useEffect(state, (s) => s.list())
  337. }),
  338. defaultInfo: Effect.fn("Agent.defaultInfo")(function* () {
  339. return yield* InstanceState.useEffect(state, (s) => s.defaultInfo())
  340. }),
  341. defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
  342. return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
  343. }),
  344. generate: Effect.fn("Agent.generate")(function* (input: {
  345. description: string
  346. model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
  347. }) {
  348. const cfg = yield* config.get()
  349. const model = input.model ?? (yield* provider.defaultModel())
  350. const resolved = yield* provider.getModel(model.providerID, model.modelID)
  351. const language = yield* provider.getLanguage(resolved)
  352. const tracer = cfg.experimental?.openTelemetry
  353. ? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
  354. : undefined
  355. const system = [PROMPT_GENERATE]
  356. yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
  357. const existing = yield* InstanceState.useEffect(state, (s) => s.list())
  358. // TODO: clean this up so provider specific logic doesnt bleed over
  359. const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
  360. const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
  361. const params = {
  362. experimental_telemetry: {
  363. isEnabled: cfg.experimental?.openTelemetry,
  364. tracer,
  365. metadata: {
  366. userId: cfg.username ?? "unknown",
  367. },
  368. },
  369. temperature: 0.3,
  370. messages: [
  371. ...(isOpenaiOauth
  372. ? []
  373. : system.map(
  374. (item): ModelMessage => ({
  375. role: "system",
  376. content: item,
  377. }),
  378. )),
  379. {
  380. role: "user",
  381. 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`,
  382. },
  383. ],
  384. model: language,
  385. schema: Object.assign(
  386. Schema.toStandardSchemaV1(GeneratedAgent),
  387. Schema.toStandardJSONSchemaV1(GeneratedAgent),
  388. ),
  389. } satisfies Parameters<typeof generateObject>[0]
  390. if (isOpenaiOauth) {
  391. return yield* Effect.promise(async () => {
  392. const result = streamObject({
  393. ...params,
  394. providerOptions: ProviderTransform.providerOptions(resolved, {
  395. instructions: system.join("\n"),
  396. store: false,
  397. }),
  398. onError: () => {},
  399. })
  400. for await (const part of result.fullStream) {
  401. if (part.type === "error") throw part.error
  402. }
  403. return result.object
  404. })
  405. }
  406. return yield* Effect.promise(() => generateObject(params).then((r) => r.object))
  407. }),
  408. })
  409. }),
  410. )
  411. export const defaultLayer = layer.pipe(
  412. Layer.provide(Plugin.defaultLayer),
  413. Layer.provide(Provider.defaultLayer),
  414. Layer.provide(Auth.defaultLayer),
  415. Layer.provide(Config.defaultLayer),
  416. Layer.provide(Skill.defaultLayer),
  417. Layer.provide(LocationServiceMap.layer),
  418. )
  419. const locationServiceMapNode = LayerNode.make(LocationServiceMap.layer, [])
  420. export const node = LayerNode.make(layer, [
  421. Config.node,
  422. Auth.node,
  423. Plugin.node,
  424. Skill.node,
  425. Provider.node,
  426. locationServiceMapNode,
  427. ])
  428. export * as Agent from "./agent"