agent.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. import { Config } from "@/config/config"
  2. import z from "zod"
  3. import { Provider } from "@/provider/provider"
  4. import { ModelID, ProviderID } from "../provider/schema"
  5. import { generateObject, streamObject, type ModelMessage } from "ai"
  6. import { Truncate } from "@/tool/truncate"
  7. import { Auth } from "../auth"
  8. import { ProviderTransform } from "@/provider/transform"
  9. import PROMPT_GENERATE from "./generate.txt"
  10. import PROMPT_COMPACTION from "./prompt/compaction.txt"
  11. import PROMPT_EXPLORE from "./prompt/explore.txt"
  12. import PROMPT_SCOUT from "./prompt/scout.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 { Flag } from "@opencode-ai/core/flag/flag"
  19. import path from "path"
  20. import { Plugin } from "@/plugin"
  21. import { Skill } from "../skill"
  22. import { Effect, Context, Layer, Schema } from "effect"
  23. import { InstanceState } from "@/effect/instance-state"
  24. import * as Option from "effect/Option"
  25. import * as OtelTracer from "@effect/opentelemetry/Tracer"
  26. import { zod } from "@opencode-ai/core/effect-zod"
  27. import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
  28. import { Reference } from "@/reference/reference"
  29. export const Info = Schema.Struct({
  30. name: Schema.String,
  31. description: Schema.optional(Schema.String),
  32. mode: Schema.Literals(["subagent", "primary", "all"]),
  33. native: Schema.optional(Schema.Boolean),
  34. hidden: Schema.optional(Schema.Boolean),
  35. topP: Schema.optional(Schema.Finite),
  36. temperature: Schema.optional(Schema.Finite),
  37. color: Schema.optional(Schema.String),
  38. permission: Permission.Ruleset,
  39. model: Schema.optional(
  40. Schema.Struct({
  41. modelID: ModelID,
  42. providerID: ProviderID,
  43. }),
  44. ),
  45. variant: Schema.optional(Schema.String),
  46. prompt: Schema.optional(Schema.String),
  47. options: Schema.Record(Schema.String, Schema.Unknown),
  48. steps: Schema.optional(Schema.Finite),
  49. })
  50. .annotate({ identifier: "Agent" })
  51. .pipe(withStatics((s) => ({ zod: zod(s) })))
  52. export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
  53. export interface Interface {
  54. readonly get: (agent: string) => Effect.Effect<Info>
  55. readonly list: () => Effect.Effect<Info[]>
  56. readonly defaultAgent: () => Effect.Effect<string>
  57. readonly generate: (input: {
  58. description: string
  59. model?: { providerID: ProviderID; modelID: ModelID }
  60. }) => Effect.Effect<{
  61. identifier: string
  62. whenToUse: string
  63. systemPrompt: string
  64. }>
  65. }
  66. type State = Omit<Interface, "generate">
  67. export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
  68. export const layer = Layer.effect(
  69. Service,
  70. Effect.gen(function* () {
  71. const config = yield* Config.Service
  72. const auth = yield* Auth.Service
  73. const plugin = yield* Plugin.Service
  74. const skill = yield* Skill.Service
  75. const provider = yield* Provider.Service
  76. const state = yield* InstanceState.make<State>(
  77. Effect.fn("Agent.state")(function* (ctx) {
  78. const cfg = yield* config.get()
  79. const skillDirs = yield* skill.dirs()
  80. const whitelistedDirs = [
  81. Truncate.GLOB,
  82. path.join(Global.Path.tmp, "*"),
  83. ...skillDirs.map((dir) => path.join(dir, "*")),
  84. ]
  85. const readonlyExternalDirectory = {
  86. "*": "ask",
  87. ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
  88. } satisfies Record<string, "allow" | "ask" | "deny">
  89. const defaults = Permission.fromConfig({
  90. "*": "allow",
  91. doom_loop: "ask",
  92. external_directory: {
  93. "*": "ask",
  94. ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
  95. },
  96. question: "deny",
  97. plan_enter: "deny",
  98. plan_exit: "deny",
  99. repo_clone: "deny",
  100. repo_overview: "deny",
  101. // mirrors github.com/github/gitignore Node.gitignore pattern for .env files
  102. read: {
  103. "*": "allow",
  104. "*.env": "ask",
  105. "*.env.*": "ask",
  106. "*.env.example": "allow",
  107. },
  108. })
  109. const user = Permission.fromConfig(cfg.permission ?? {})
  110. const agents: Record<string, Info> = {
  111. build: {
  112. name: "build",
  113. description: "The default agent. Executes tools based on configured permissions.",
  114. options: {},
  115. permission: Permission.merge(
  116. defaults,
  117. Permission.fromConfig({
  118. question: "allow",
  119. plan_enter: "allow",
  120. }),
  121. user,
  122. ),
  123. mode: "primary",
  124. native: true,
  125. },
  126. plan: {
  127. name: "plan",
  128. description: "Plan mode. Disallows all edit tools.",
  129. options: {},
  130. permission: Permission.merge(
  131. defaults,
  132. Permission.fromConfig({
  133. question: "allow",
  134. plan_exit: "allow",
  135. external_directory: {
  136. [path.join(Global.Path.data, "plans", "*")]: "allow",
  137. },
  138. edit: {
  139. "*": "deny",
  140. [path.join(".opencode", "plans", "*.md")]: "allow",
  141. [path.relative(ctx.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
  142. },
  143. }),
  144. user,
  145. ),
  146. mode: "primary",
  147. native: true,
  148. },
  149. general: {
  150. name: "general",
  151. description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
  152. permission: Permission.merge(
  153. defaults,
  154. Permission.fromConfig({
  155. todowrite: "deny",
  156. }),
  157. user,
  158. ),
  159. options: {},
  160. mode: "subagent",
  161. native: true,
  162. },
  163. explore: {
  164. name: "explore",
  165. permission: Permission.merge(
  166. defaults,
  167. Permission.fromConfig({
  168. "*": "deny",
  169. grep: "allow",
  170. glob: "allow",
  171. list: "allow",
  172. bash: "allow",
  173. webfetch: "allow",
  174. websearch: "allow",
  175. read: "allow",
  176. external_directory: readonlyExternalDirectory,
  177. }),
  178. user,
  179. ),
  180. 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.`,
  181. prompt: PROMPT_EXPLORE,
  182. options: {},
  183. mode: "subagent",
  184. native: true,
  185. },
  186. ...(Flag.OPENCODE_EXPERIMENTAL_SCOUT
  187. ? {
  188. scout: {
  189. name: "scout",
  190. permission: Permission.merge(
  191. defaults,
  192. Permission.fromConfig({
  193. "*": "deny",
  194. grep: "allow",
  195. glob: "allow",
  196. webfetch: "allow",
  197. websearch: "allow",
  198. codesearch: "allow",
  199. read: "allow",
  200. repo_clone: "allow",
  201. repo_overview: "allow",
  202. external_directory: {
  203. ...readonlyExternalDirectory,
  204. [path.join(Global.Path.repos, "*")]: "allow",
  205. },
  206. }),
  207. user,
  208. ),
  209. description: `Docs and dependency-source specialist. Use this when you need to inspect external documentation, clone dependency repositories into the managed cache, and research library implementation details without modifying the user's workspace.`,
  210. prompt: PROMPT_SCOUT,
  211. options: {},
  212. mode: "subagent" as const,
  213. native: true,
  214. },
  215. }
  216. : {}),
  217. compaction: {
  218. name: "compaction",
  219. mode: "primary",
  220. native: true,
  221. hidden: true,
  222. prompt: PROMPT_COMPACTION,
  223. permission: Permission.merge(
  224. defaults,
  225. Permission.fromConfig({
  226. "*": "deny",
  227. }),
  228. user,
  229. ),
  230. options: {},
  231. },
  232. title: {
  233. name: "title",
  234. mode: "primary",
  235. options: {},
  236. native: true,
  237. hidden: true,
  238. temperature: 0.5,
  239. permission: Permission.merge(
  240. defaults,
  241. Permission.fromConfig({
  242. "*": "deny",
  243. }),
  244. user,
  245. ),
  246. prompt: PROMPT_TITLE,
  247. },
  248. summary: {
  249. name: "summary",
  250. mode: "primary",
  251. options: {},
  252. native: true,
  253. hidden: true,
  254. permission: Permission.merge(
  255. defaults,
  256. Permission.fromConfig({
  257. "*": "deny",
  258. }),
  259. user,
  260. ),
  261. prompt: PROMPT_SUMMARY,
  262. },
  263. }
  264. for (const [key, value] of Object.entries(cfg.agent ?? {})) {
  265. if (value.disable) {
  266. delete agents[key]
  267. continue
  268. }
  269. let item = agents[key]
  270. if (!item)
  271. item = agents[key] = {
  272. name: key,
  273. mode: "all",
  274. permission: Permission.merge(defaults, user),
  275. options: {},
  276. native: false,
  277. }
  278. if (value.model) item.model = Provider.parseModel(value.model)
  279. item.variant = value.variant ?? item.variant
  280. item.prompt = value.prompt ?? item.prompt
  281. item.description = value.description ?? item.description
  282. item.temperature = value.temperature ?? item.temperature
  283. item.topP = value.top_p ?? item.topP
  284. item.mode = value.mode ?? item.mode
  285. item.color = value.color ?? item.color
  286. item.hidden = value.hidden ?? item.hidden
  287. item.name = value.name ?? item.name
  288. item.steps = value.steps ?? item.steps
  289. item.options = mergeDeep(item.options, value.options ?? {})
  290. item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
  291. }
  292. function referencePrompt(reference: Reference.Resolved) {
  293. if (reference.kind === "local") {
  294. return [
  295. `You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
  296. `Local directory: ${reference.path}`,
  297. `Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
  298. `Return exact absolute file paths for findings whenever possible.`,
  299. ].join("\n\n")
  300. }
  301. if (reference.kind === "invalid") {
  302. return [
  303. `You are configured reference @${reference.name}, but this reference is not usable yet.`,
  304. `Configured repository: ${reference.repository}`,
  305. `Problem: ${reference.message}`,
  306. `Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`,
  307. ].join("\n\n")
  308. }
  309. return [
  310. `You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
  311. `Repository: ${reference.repository}`,
  312. ...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
  313. `Cached directory: ${reference.path}`,
  314. `OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`,
  315. `Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`,
  316. `Return exact absolute file paths for findings whenever possible.`,
  317. ].join("\n\n")
  318. }
  319. function referenceDescription(reference: Reference.Resolved) {
  320. if (reference.kind === "local") return `Scout reference for local directory ${reference.path}`
  321. if (reference.kind === "git") return `Scout reference for repository ${reference.repository}`
  322. return `Invalid Scout reference for repository ${reference.repository}`
  323. }
  324. if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
  325. const resolvedReferences = Reference.resolveAll({
  326. references: cfg.reference ?? {},
  327. directory: ctx.directory,
  328. worktree: ctx.worktree,
  329. })
  330. for (const resolved of resolvedReferences) {
  331. if (agents[resolved.name]) continue
  332. const localPath = resolved.kind === "invalid" ? undefined : resolved.path
  333. agents[resolved.name] = {
  334. name: resolved.name,
  335. description: referenceDescription(resolved),
  336. permission: Permission.merge(
  337. agents.scout.permission,
  338. Permission.fromConfig({
  339. repo_clone: "deny",
  340. ...(localPath
  341. ? {
  342. external_directory: {
  343. [localPath]: "allow",
  344. [path.join(localPath, "*")]: "allow",
  345. },
  346. }
  347. : {}),
  348. }),
  349. ),
  350. prompt: referencePrompt(resolved),
  351. options: { reference: cfg.reference?.[resolved.name], resolved },
  352. mode: "subagent",
  353. native: false,
  354. }
  355. }
  356. }
  357. // Ensure Truncate.GLOB is allowed unless explicitly configured
  358. for (const name in agents) {
  359. const agent = agents[name]
  360. const explicit = agent.permission.some((r) => {
  361. if (r.permission !== "external_directory") return false
  362. if (r.action !== "deny") return false
  363. return r.pattern === Truncate.GLOB
  364. })
  365. if (explicit) continue
  366. agents[name].permission = Permission.merge(
  367. agents[name].permission,
  368. Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
  369. )
  370. }
  371. const get = Effect.fnUntraced(function* (agent: string) {
  372. return agents[agent]
  373. })
  374. const list = Effect.fnUntraced(function* () {
  375. const cfg = yield* config.get()
  376. return pipe(
  377. agents,
  378. values(),
  379. sortBy(
  380. [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
  381. [(x) => x.name, "asc"],
  382. ),
  383. )
  384. })
  385. const defaultAgent = Effect.fnUntraced(function* () {
  386. const c = yield* config.get()
  387. if (c.default_agent) {
  388. const agent = agents[c.default_agent]
  389. if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
  390. if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
  391. if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
  392. return agent.name
  393. }
  394. const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
  395. if (!visible) throw new Error("no primary visible agent found")
  396. return visible.name
  397. })
  398. return {
  399. get,
  400. list,
  401. defaultAgent,
  402. } satisfies State
  403. }),
  404. )
  405. return Service.of({
  406. get: Effect.fn("Agent.get")(function* (agent: string) {
  407. return yield* InstanceState.useEffect(state, (s) => s.get(agent))
  408. }),
  409. list: Effect.fn("Agent.list")(function* () {
  410. return yield* InstanceState.useEffect(state, (s) => s.list())
  411. }),
  412. defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
  413. return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
  414. }),
  415. generate: Effect.fn("Agent.generate")(function* (input: {
  416. description: string
  417. model?: { providerID: ProviderID; modelID: ModelID }
  418. }) {
  419. const cfg = yield* config.get()
  420. const model = input.model ?? (yield* provider.defaultModel())
  421. const resolved = yield* provider.getModel(model.providerID, model.modelID)
  422. const language = yield* provider.getLanguage(resolved)
  423. const tracer = cfg.experimental?.openTelemetry
  424. ? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
  425. : undefined
  426. const system = [PROMPT_GENERATE]
  427. yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
  428. const existing = yield* InstanceState.useEffect(state, (s) => s.list())
  429. // TODO: clean this up so provider specific logic doesnt bleed over
  430. const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
  431. const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
  432. const params = {
  433. experimental_telemetry: {
  434. isEnabled: cfg.experimental?.openTelemetry,
  435. tracer,
  436. metadata: {
  437. userId: cfg.username ?? "unknown",
  438. },
  439. },
  440. temperature: 0.3,
  441. messages: [
  442. ...(isOpenaiOauth
  443. ? []
  444. : system.map(
  445. (item): ModelMessage => ({
  446. role: "system",
  447. content: item,
  448. }),
  449. )),
  450. {
  451. role: "user",
  452. 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`,
  453. },
  454. ],
  455. model: language,
  456. schema: z.object({
  457. identifier: z.string(),
  458. whenToUse: z.string(),
  459. systemPrompt: z.string(),
  460. }),
  461. } satisfies Parameters<typeof generateObject>[0]
  462. if (isOpenaiOauth) {
  463. return yield* Effect.promise(async () => {
  464. const result = streamObject({
  465. ...params,
  466. providerOptions: ProviderTransform.providerOptions(resolved, {
  467. instructions: system.join("\n"),
  468. store: false,
  469. }),
  470. onError: () => {},
  471. })
  472. for await (const part of result.fullStream) {
  473. if (part.type === "error") throw part.error
  474. }
  475. return result.object
  476. })
  477. }
  478. return yield* Effect.promise(() => generateObject(params).then((r) => r.object))
  479. }),
  480. })
  481. }),
  482. )
  483. export const defaultLayer = layer.pipe(
  484. Layer.provide(Plugin.defaultLayer),
  485. Layer.provide(Provider.defaultLayer),
  486. Layer.provide(Auth.defaultLayer),
  487. Layer.provide(Config.defaultLayer),
  488. Layer.provide(Skill.defaultLayer),
  489. )
  490. export * as Agent from "./agent"