agent.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. export * as ConfigAgentPlugin from "./agent"
  2. import { define } from "../../plugin/internal"
  3. import path from "path"
  4. import { Effect, Option, Schema } from "effect"
  5. import { AgentV2 } from "../../agent"
  6. import { Config } from "../../config"
  7. import { ConfigAgent } from "../agent"
  8. import { ConfigMarkdown } from "../markdown"
  9. import { FSUtil } from "../../fs-util"
  10. import { ModelV2 } from "../../model"
  11. import { ConfigAgentV1 } from "../../v1/config/agent"
  12. import { ConfigMigrateV1 } from "../../v1/config/migrate"
  13. import { Global } from "../../global"
  14. import { PermissionV2 } from "../../permission"
  15. import type { LocationMutation } from "../../location-mutation"
  16. import type { ReadTool } from "../../tool/read"
  17. import type { EditTool } from "../../tool/edit"
  18. const legacySources = [
  19. { pattern: "{agent,agents}/**/*.md", primary: false },
  20. { pattern: "{mode,modes}/*.md", primary: true },
  21. ] as const
  22. const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
  23. const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
  24. const decodeConfig = Schema.decodeUnknownOption(Config.Info)
  25. type PathAction =
  26. | LocationMutation.ExternalDirectoryAuthorization["action"]
  27. | typeof ReadTool.name
  28. | typeof EditTool.name
  29. const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
  30. const agentKeys = new Set([
  31. "model",
  32. "variant",
  33. "request",
  34. "system",
  35. "description",
  36. "mode",
  37. "hidden",
  38. "color",
  39. "steps",
  40. "disabled",
  41. "permissions",
  42. ])
  43. export const Plugin = define({
  44. id: "config-agent",
  45. effect: Effect.fn(function* (ctx) {
  46. const config = yield* Config.Service
  47. const fs = yield* FSUtil.Service
  48. const global = yield* Global.Service
  49. yield* ctx.agent.transform(
  50. Effect.fn(function* (draft) {
  51. const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
  52. if (entry.type === "document") return Effect.succeed([entry])
  53. return Effect.gen(function* () {
  54. const files = yield* discover(fs, entry.path)
  55. return yield* Effect.forEach(files, (file) =>
  56. fs.readFileStringSafe(file.filepath).pipe(
  57. Effect.map((content) => content && decode(file, content)),
  58. Effect.catch(() => Effect.succeed(undefined)),
  59. ),
  60. ).pipe(
  61. Effect.map((documents) =>
  62. documents.filter((document): document is Config.Document => document !== undefined),
  63. ),
  64. )
  65. })
  66. }).pipe(Effect.map((documents) => documents.flat()))
  67. const permissions = expandPermissions(
  68. documents.flatMap((document) => document.info.permissions ?? []),
  69. global.home,
  70. )
  71. const configuredDefault = Config.latest(documents, "default_agent")
  72. if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
  73. for (const current of draft.list()) {
  74. draft.update(current.id, (agent) => agent.permissions.push(...permissions))
  75. }
  76. for (const document of documents) {
  77. for (const [id, item] of Object.entries(document.info.agents ?? {})) {
  78. const agentID = AgentV2.ID.make(id)
  79. if (item.disabled) {
  80. draft.remove(agentID)
  81. continue
  82. }
  83. const exists = draft.get(agentID) !== undefined
  84. draft.update(agentID, (agent) => {
  85. if (!exists) agent.permissions.push(...permissions)
  86. if (item.model !== undefined) {
  87. const model = ModelV2.parse(item.model)
  88. agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
  89. }
  90. if (item.variant !== undefined && agent.model !== undefined) {
  91. agent.model.variant = ModelV2.VariantID.make(item.variant)
  92. }
  93. if (item.request !== undefined) {
  94. Object.assign(agent.request.headers, item.request.headers ?? {})
  95. Object.assign(agent.request.body, item.request.body ?? {})
  96. }
  97. if (item.system !== undefined) agent.system = item.system
  98. if (item.description !== undefined) agent.description = item.description
  99. if (item.mode !== undefined) agent.mode = item.mode
  100. if (item.hidden !== undefined) agent.hidden = item.hidden
  101. if (item.color !== undefined) agent.color = item.color
  102. if (item.steps !== undefined) agent.steps = item.steps
  103. if (item.permissions !== undefined) {
  104. agent.permissions.push(...expandPermissions(item.permissions, global.home))
  105. }
  106. })
  107. }
  108. }
  109. }),
  110. )
  111. }),
  112. })
  113. function expandPermissions(rules: PermissionV2.Ruleset, home: string): PermissionV2.Ruleset {
  114. // Expand only resources tools resolve as filesystem paths. Bash resources are raw shell text:
  115. // rewriting `$HOME/private/**` would miss `$HOME/private/key`, and safe expansion needs shell-aware parsing.
  116. return rules.map((rule) =>
  117. isPathAction(rule.action) ? { ...rule, resource: expandHome(rule.resource, home) } : rule,
  118. )
  119. }
  120. function isPathAction(action: string): action is PathAction {
  121. return pathActions.some((item) => item === action)
  122. }
  123. function expandHome(resource: string, home: string) {
  124. if (resource.startsWith("~/")) return home + resource.slice(1)
  125. if (resource === "~") return home
  126. if (resource === "$HOME") return home
  127. if (resource.startsWith("$HOME/")) return home + resource.slice(5)
  128. if (resource.startsWith("$HOME\\")) return home + resource.slice(5)
  129. return resource
  130. }
  131. function discover(fs: FSUtil.Interface, directory: string) {
  132. return Effect.forEach(legacySources, (source) =>
  133. fs
  134. .glob(source.pattern, { cwd: directory, absolute: true, dot: true, symlink: true })
  135. .pipe(
  136. Effect.map((files) => files.toSorted().map((filepath) => ({ directory, filepath, primary: source.primary }))),
  137. ),
  138. ).pipe(
  139. Effect.map((files) => files.flat()),
  140. Effect.catch(() => Effect.succeed([])),
  141. )
  142. }
  143. function decode(file: { directory: string; filepath: string; primary: boolean }, content: string) {
  144. const markdown = ConfigMarkdown.parseOption(content)
  145. if (!markdown) return
  146. const name = path
  147. .relative(file.directory, file.filepath)
  148. .replaceAll("\\", "/")
  149. .replace(/^(agent|agents|mode|modes)\//, "")
  150. .replace(/\.md$/, "")
  151. const body = markdown.content.trim()
  152. const legacy = Object.keys(markdown.data).some((key) => !agentKeys.has(key))
  153. const agent = Option.getOrUndefined(
  154. legacy
  155. ? Option.map(
  156. decodeLegacyAgent({ name, ...markdown.data, prompt: body }, { errors: "all", propertyOrder: "original" }),
  157. ConfigMigrateV1.migrateAgent,
  158. )
  159. : decodeAgent({ ...markdown.data, system: body }, { errors: "all", propertyOrder: "original" }),
  160. )
  161. if (!agent) return
  162. const info = Option.getOrUndefined(
  163. decodeConfig({
  164. agents: { [name]: file.primary ? { ...agent, mode: "primary" } : agent },
  165. }),
  166. )
  167. if (!info) return
  168. return new Config.Document({ type: "document", path: file.filepath, info })
  169. }