agent.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825
  1. import {
  2. RequestError,
  3. type Agent as ACPAgent,
  4. type AgentSideConnection,
  5. type AuthenticateRequest,
  6. type AuthMethod,
  7. type CancelNotification,
  8. type InitializeRequest,
  9. type InitializeResponse,
  10. type LoadSessionRequest,
  11. type NewSessionRequest,
  12. type PermissionOption,
  13. type PlanEntry,
  14. type PromptRequest,
  15. type SetSessionModelRequest,
  16. type SetSessionModeRequest,
  17. type SetSessionModeResponse,
  18. type ToolCallContent,
  19. type ToolKind,
  20. } from "@agentclientprotocol/sdk"
  21. import { Log } from "../util/log"
  22. import { ACPSessionManager } from "./session"
  23. import type { ACPConfig, ACPSessionState } from "./types"
  24. import { Provider } from "../provider/provider"
  25. import { Installation } from "@/installation"
  26. import { MessageV2 } from "@/session/message-v2"
  27. import { Config } from "@/config/config"
  28. import { MCP } from "@/mcp"
  29. import { Todo } from "@/session/todo"
  30. import { z } from "zod"
  31. import { LoadAPIKeyError } from "ai"
  32. import type { OpencodeClient } from "@opencode-ai/sdk"
  33. export namespace ACP {
  34. const log = Log.create({ service: "acp-agent" })
  35. export async function init({ sdk }: { sdk: OpencodeClient }) {
  36. const model = await defaultModel({ sdk })
  37. return {
  38. create: (connection: AgentSideConnection, fullConfig: ACPConfig) => {
  39. if (!fullConfig.defaultModel) {
  40. fullConfig.defaultModel = model
  41. }
  42. return new Agent(connection, fullConfig)
  43. },
  44. }
  45. }
  46. export class Agent implements ACPAgent {
  47. private connection: AgentSideConnection
  48. private config: ACPConfig
  49. private sdk: OpencodeClient
  50. private sessionManager
  51. constructor(connection: AgentSideConnection, config: ACPConfig) {
  52. this.connection = connection
  53. this.config = config
  54. this.sdk = config.sdk
  55. this.sessionManager = new ACPSessionManager(this.sdk)
  56. }
  57. private setupEventSubscriptions(session: ACPSessionState) {
  58. const sessionId = session.id
  59. const directory = session.cwd
  60. const options: PermissionOption[] = [
  61. { optionId: "once", kind: "allow_once", name: "Allow once" },
  62. { optionId: "always", kind: "allow_always", name: "Always allow" },
  63. { optionId: "reject", kind: "reject_once", name: "Reject" },
  64. ]
  65. this.config.sdk.event.subscribe({ query: { directory } }).then(async (events) => {
  66. for await (const event of events.stream) {
  67. switch (event.type) {
  68. case "permission.updated":
  69. try {
  70. const permission = event.properties
  71. const res = await this.connection
  72. .requestPermission({
  73. sessionId,
  74. toolCall: {
  75. toolCallId: permission.callID ?? permission.id,
  76. status: "pending",
  77. title: permission.title,
  78. rawInput: permission.metadata,
  79. kind: toToolKind(permission.type),
  80. locations: toLocations(permission.type, permission.metadata),
  81. },
  82. options,
  83. })
  84. .catch(async (error) => {
  85. log.error("failed to request permission from ACP", {
  86. error,
  87. permissionID: permission.id,
  88. sessionID: permission.sessionID,
  89. })
  90. await this.config.sdk.postSessionIdPermissionsPermissionId({
  91. path: { id: permission.sessionID, permissionID: permission.id },
  92. body: {
  93. response: "reject",
  94. },
  95. query: { directory },
  96. })
  97. return
  98. })
  99. if (!res) return
  100. if (res.outcome.outcome !== "selected") {
  101. await this.config.sdk.postSessionIdPermissionsPermissionId({
  102. path: { id: permission.sessionID, permissionID: permission.id },
  103. body: {
  104. response: "reject",
  105. },
  106. query: { directory },
  107. })
  108. return
  109. }
  110. await this.config.sdk.postSessionIdPermissionsPermissionId({
  111. path: { id: permission.sessionID, permissionID: permission.id },
  112. body: {
  113. response: res.outcome.optionId as "once" | "always" | "reject",
  114. },
  115. query: { directory },
  116. })
  117. } catch (err) {
  118. log.error("unexpected error when handling permission", { error: err })
  119. } finally {
  120. break
  121. }
  122. case "message.part.updated":
  123. log.info("message part updated", { event: event.properties })
  124. try {
  125. const props = event.properties
  126. const { part } = props
  127. const message = await this.config.sdk.session
  128. .message({
  129. throwOnError: true,
  130. path: {
  131. id: part.sessionID,
  132. messageID: part.messageID,
  133. },
  134. query: { directory },
  135. })
  136. .then((x) => x.data)
  137. .catch((err) => {
  138. log.error("unexpected error when fetching message", { error: err })
  139. return undefined
  140. })
  141. if (!message || message.info.role !== "assistant") return
  142. if (part.type === "tool") {
  143. switch (part.state.status) {
  144. case "pending":
  145. await this.connection
  146. .sessionUpdate({
  147. sessionId,
  148. update: {
  149. sessionUpdate: "tool_call",
  150. toolCallId: part.callID,
  151. title: part.tool,
  152. kind: toToolKind(part.tool),
  153. status: "pending",
  154. locations: [],
  155. rawInput: {},
  156. },
  157. })
  158. .catch((err) => {
  159. log.error("failed to send tool pending to ACP", { error: err })
  160. })
  161. break
  162. case "running":
  163. await this.connection
  164. .sessionUpdate({
  165. sessionId,
  166. update: {
  167. sessionUpdate: "tool_call_update",
  168. toolCallId: part.callID,
  169. status: "in_progress",
  170. locations: toLocations(part.tool, part.state.input),
  171. rawInput: part.state.input,
  172. },
  173. })
  174. .catch((err) => {
  175. log.error("failed to send tool in_progress to ACP", { error: err })
  176. })
  177. break
  178. case "completed":
  179. const kind = toToolKind(part.tool)
  180. const content: ToolCallContent[] = [
  181. {
  182. type: "content",
  183. content: {
  184. type: "text",
  185. text: part.state.output,
  186. },
  187. },
  188. ]
  189. if (kind === "edit") {
  190. const input = part.state.input
  191. const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
  192. const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
  193. const newText =
  194. typeof input["newString"] === "string"
  195. ? input["newString"]
  196. : typeof input["content"] === "string"
  197. ? input["content"]
  198. : ""
  199. content.push({
  200. type: "diff",
  201. path: filePath,
  202. oldText,
  203. newText,
  204. })
  205. }
  206. if (part.tool === "todowrite") {
  207. const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
  208. if (parsedTodos.success) {
  209. await this.connection
  210. .sessionUpdate({
  211. sessionId,
  212. update: {
  213. sessionUpdate: "plan",
  214. entries: parsedTodos.data.map((todo) => {
  215. const status: PlanEntry["status"] =
  216. todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
  217. return {
  218. priority: "medium",
  219. status,
  220. content: todo.content,
  221. }
  222. }),
  223. },
  224. })
  225. .catch((err) => {
  226. log.error("failed to send session update for todo", { error: err })
  227. })
  228. } else {
  229. log.error("failed to parse todo output", { error: parsedTodos.error })
  230. }
  231. }
  232. await this.connection
  233. .sessionUpdate({
  234. sessionId,
  235. update: {
  236. sessionUpdate: "tool_call_update",
  237. toolCallId: part.callID,
  238. status: "completed",
  239. kind,
  240. content,
  241. title: part.state.title,
  242. rawOutput: {
  243. output: part.state.output,
  244. metadata: part.state.metadata,
  245. },
  246. },
  247. })
  248. .catch((err) => {
  249. log.error("failed to send tool completed to ACP", { error: err })
  250. })
  251. break
  252. case "error":
  253. await this.connection
  254. .sessionUpdate({
  255. sessionId,
  256. update: {
  257. sessionUpdate: "tool_call_update",
  258. toolCallId: part.callID,
  259. status: "failed",
  260. content: [
  261. {
  262. type: "content",
  263. content: {
  264. type: "text",
  265. text: part.state.error,
  266. },
  267. },
  268. ],
  269. rawOutput: {
  270. error: part.state.error,
  271. },
  272. },
  273. })
  274. .catch((err) => {
  275. log.error("failed to send tool error to ACP", { error: err })
  276. })
  277. break
  278. }
  279. } else if (part.type === "text") {
  280. const delta = props.delta
  281. if (delta && part.synthetic !== true) {
  282. await this.connection
  283. .sessionUpdate({
  284. sessionId,
  285. update: {
  286. sessionUpdate: "agent_message_chunk",
  287. content: {
  288. type: "text",
  289. text: delta,
  290. },
  291. },
  292. })
  293. .catch((err) => {
  294. log.error("failed to send text to ACP", { error: err })
  295. })
  296. }
  297. } else if (part.type === "reasoning") {
  298. const delta = props.delta
  299. if (delta) {
  300. await this.connection
  301. .sessionUpdate({
  302. sessionId,
  303. update: {
  304. sessionUpdate: "agent_thought_chunk",
  305. content: {
  306. type: "text",
  307. text: delta,
  308. },
  309. },
  310. })
  311. .catch((err) => {
  312. log.error("failed to send reasoning to ACP", { error: err })
  313. })
  314. }
  315. }
  316. } finally {
  317. break
  318. }
  319. }
  320. }
  321. })
  322. }
  323. async initialize(params: InitializeRequest): Promise<InitializeResponse> {
  324. log.info("initialize", { protocolVersion: params.protocolVersion })
  325. const authMethod: AuthMethod = {
  326. description: "Run `opencode auth login` in the terminal",
  327. name: "Login with opencode",
  328. id: "opencode-login",
  329. }
  330. // If client supports terminal-auth capability, use that instead.
  331. if (params.clientCapabilities?._meta?.["terminal-auth"] === true) {
  332. authMethod._meta = {
  333. "terminal-auth": {
  334. command: "opencode",
  335. args: ["auth", "login"],
  336. label: "OpenCode Login",
  337. },
  338. }
  339. }
  340. return {
  341. protocolVersion: 1,
  342. agentCapabilities: {
  343. loadSession: true,
  344. mcpCapabilities: {
  345. http: true,
  346. sse: true,
  347. },
  348. promptCapabilities: {
  349. embeddedContext: true,
  350. image: true,
  351. },
  352. },
  353. authMethods: [authMethod],
  354. agentInfo: {
  355. name: "OpenCode",
  356. version: Installation.VERSION,
  357. },
  358. }
  359. }
  360. async authenticate(_params: AuthenticateRequest) {
  361. throw new Error("Authentication not implemented")
  362. }
  363. async newSession(params: NewSessionRequest) {
  364. const directory = params.cwd
  365. try {
  366. const model = await defaultModel(this.config, directory)
  367. // Store ACP session state
  368. const state = await this.sessionManager.create(params.cwd, params.mcpServers, model)
  369. const sessionId = state.id
  370. log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length })
  371. const load = await this.loadSession({
  372. cwd: directory,
  373. mcpServers: params.mcpServers,
  374. sessionId,
  375. })
  376. this.setupEventSubscriptions(state)
  377. return {
  378. sessionId,
  379. models: load.models,
  380. modes: load.modes,
  381. _meta: {},
  382. }
  383. } catch (e) {
  384. const error = MessageV2.fromError(e, {
  385. providerID: this.config.defaultModel?.providerID ?? "unknown",
  386. })
  387. if (LoadAPIKeyError.isInstance(error)) {
  388. throw RequestError.authRequired()
  389. }
  390. throw e
  391. }
  392. }
  393. async loadSession(params: LoadSessionRequest) {
  394. const directory = params.cwd
  395. const model = await defaultModel(this.config, directory)
  396. const sessionId = params.sessionId
  397. const providers = await this.sdk.config
  398. .providers({ throwOnError: true, query: { directory } })
  399. .then((x) => x.data.providers)
  400. const entries = providers.sort((a, b) => {
  401. const nameA = a.name.toLowerCase()
  402. const nameB = b.name.toLowerCase()
  403. if (nameA < nameB) return -1
  404. if (nameA > nameB) return 1
  405. return 0
  406. })
  407. const availableModels = entries.flatMap((provider) => {
  408. const models = Provider.sort(Object.values(provider.models))
  409. return models.map((model) => ({
  410. modelId: `${provider.id}/${model.id}`,
  411. name: `${provider.name}/${model.name}`,
  412. }))
  413. })
  414. const agents = await this.config.sdk.app
  415. .agents({
  416. throwOnError: true,
  417. query: {
  418. directory,
  419. },
  420. })
  421. .then((resp) => resp.data)
  422. const commands = await this.config.sdk.command
  423. .list({
  424. throwOnError: true,
  425. query: {
  426. directory,
  427. },
  428. })
  429. .then((resp) => resp.data)
  430. const availableCommands = commands.map((command) => ({
  431. name: command.name,
  432. description: command.description ?? "",
  433. }))
  434. const names = new Set(availableCommands.map((c) => c.name))
  435. if (!names.has("compact"))
  436. availableCommands.push({
  437. name: "compact",
  438. description: "compact the session",
  439. })
  440. const availableModes = agents
  441. .filter((agent) => agent.mode !== "subagent")
  442. .map((agent) => ({
  443. id: agent.name,
  444. name: agent.name,
  445. description: agent.description,
  446. }))
  447. const currentModeId = availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id
  448. const mcpServers: Record<string, Config.Mcp> = {}
  449. for (const server of params.mcpServers) {
  450. if ("type" in server) {
  451. mcpServers[server.name] = {
  452. url: server.url,
  453. headers: server.headers.reduce<Record<string, string>>((acc, { name, value }) => {
  454. acc[name] = value
  455. return acc
  456. }, {}),
  457. type: "remote",
  458. }
  459. } else {
  460. mcpServers[server.name] = {
  461. type: "local",
  462. command: [server.command, ...server.args],
  463. environment: server.env.reduce<Record<string, string>>((acc, { name, value }) => {
  464. acc[name] = value
  465. return acc
  466. }, {}),
  467. }
  468. }
  469. }
  470. await Promise.all(
  471. Object.entries(mcpServers).map(async ([key, mcp]) => {
  472. await this.sdk.mcp
  473. .add({
  474. throwOnError: true,
  475. query: { directory },
  476. body: {
  477. name: key,
  478. config: mcp,
  479. },
  480. })
  481. .catch((error) => {
  482. log.error("failed to add mcp server", { name: key, error })
  483. })
  484. }),
  485. )
  486. setTimeout(() => {
  487. this.connection.sessionUpdate({
  488. sessionId,
  489. update: {
  490. sessionUpdate: "available_commands_update",
  491. availableCommands,
  492. },
  493. })
  494. }, 0)
  495. return {
  496. sessionId,
  497. models: {
  498. currentModelId: `${model.providerID}/${model.modelID}`,
  499. availableModels,
  500. },
  501. modes: {
  502. availableModes,
  503. currentModeId,
  504. },
  505. _meta: {},
  506. }
  507. }
  508. async setSessionModel(params: SetSessionModelRequest) {
  509. const session = this.sessionManager.get(params.sessionId)
  510. const model = Provider.parseModel(params.modelId)
  511. this.sessionManager.setModel(session.id, {
  512. providerID: model.providerID,
  513. modelID: model.modelID,
  514. })
  515. return {
  516. _meta: {},
  517. }
  518. }
  519. async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse | void> {
  520. this.sessionManager.get(params.sessionId)
  521. await this.config.sdk.app
  522. .agents({ throwOnError: true })
  523. .then((x) => x.data)
  524. .then((agent) => {
  525. if (!agent) throw new Error(`Agent not found: ${params.modeId}`)
  526. })
  527. this.sessionManager.setMode(params.sessionId, params.modeId)
  528. }
  529. async prompt(params: PromptRequest) {
  530. const sessionID = params.sessionId
  531. const session = this.sessionManager.get(sessionID)
  532. const directory = session.cwd
  533. const current = session.model
  534. const model = current ?? (await defaultModel(this.config, directory))
  535. if (!current) {
  536. this.sessionManager.setModel(session.id, model)
  537. }
  538. const agent = session.modeId ?? "build"
  539. const parts: Array<
  540. { type: "text"; text: string } | { type: "file"; url: string; filename: string; mime: string }
  541. > = []
  542. for (const part of params.prompt) {
  543. switch (part.type) {
  544. case "text":
  545. parts.push({
  546. type: "text" as const,
  547. text: part.text,
  548. })
  549. break
  550. case "image":
  551. if (part.data) {
  552. parts.push({
  553. type: "file",
  554. url: `data:${part.mimeType};base64,${part.data}`,
  555. filename: "image",
  556. mime: part.mimeType,
  557. })
  558. } else if (part.uri && part.uri.startsWith("http:")) {
  559. parts.push({
  560. type: "file",
  561. url: part.uri,
  562. filename: "image",
  563. mime: part.mimeType,
  564. })
  565. }
  566. break
  567. case "resource_link":
  568. const parsed = parseUri(part.uri)
  569. parts.push(parsed)
  570. break
  571. case "resource":
  572. const resource = part.resource
  573. if ("text" in resource) {
  574. parts.push({
  575. type: "text",
  576. text: resource.text,
  577. })
  578. }
  579. break
  580. default:
  581. break
  582. }
  583. }
  584. log.info("parts", { parts })
  585. const cmd = (() => {
  586. const text = parts
  587. .filter((p): p is { type: "text"; text: string } => p.type === "text")
  588. .map((p) => p.text)
  589. .join("")
  590. .trim()
  591. if (!text.startsWith("/")) return
  592. const [name, ...rest] = text.slice(1).split(/\s+/)
  593. return { name, args: rest.join(" ").trim() }
  594. })()
  595. const done = {
  596. stopReason: "end_turn" as const,
  597. _meta: {},
  598. }
  599. if (!cmd) {
  600. await this.sdk.session.prompt({
  601. path: { id: sessionID },
  602. body: {
  603. model: {
  604. providerID: model.providerID,
  605. modelID: model.modelID,
  606. },
  607. parts,
  608. agent,
  609. },
  610. query: {
  611. directory,
  612. },
  613. })
  614. return done
  615. }
  616. const command = await this.config.sdk.command
  617. .list({ throwOnError: true, query: { directory } })
  618. .then((x) => x.data.find((c) => c.name === cmd.name))
  619. if (command) {
  620. await this.sdk.session.command({
  621. path: { id: sessionID },
  622. body: {
  623. command: command.name,
  624. arguments: cmd.args,
  625. model: model.providerID + "/" + model.modelID,
  626. agent,
  627. },
  628. query: {
  629. directory,
  630. },
  631. })
  632. return done
  633. }
  634. switch (cmd.name) {
  635. case "compact":
  636. await this.config.sdk.session.summarize({
  637. path: { id: sessionID },
  638. throwOnError: true,
  639. query: {
  640. directory,
  641. },
  642. })
  643. break
  644. }
  645. return done
  646. }
  647. async cancel(params: CancelNotification) {
  648. const session = this.sessionManager.get(params.sessionId)
  649. await this.config.sdk.session.abort({
  650. path: { id: params.sessionId },
  651. throwOnError: true,
  652. query: {
  653. directory: session.cwd,
  654. },
  655. })
  656. }
  657. }
  658. function toToolKind(toolName: string): ToolKind {
  659. const tool = toolName.toLocaleLowerCase()
  660. switch (tool) {
  661. case "bash":
  662. return "execute"
  663. case "webfetch":
  664. return "fetch"
  665. case "edit":
  666. case "patch":
  667. case "write":
  668. return "edit"
  669. case "grep":
  670. case "glob":
  671. case "context7_resolve_library_id":
  672. case "context7_get_library_docs":
  673. return "search"
  674. case "list":
  675. case "read":
  676. return "read"
  677. default:
  678. return "other"
  679. }
  680. }
  681. function toLocations(toolName: string, input: Record<string, any>): { path: string }[] {
  682. const tool = toolName.toLocaleLowerCase()
  683. switch (tool) {
  684. case "read":
  685. case "edit":
  686. case "write":
  687. return input["filePath"] ? [{ path: input["filePath"] }] : []
  688. case "glob":
  689. case "grep":
  690. return input["path"] ? [{ path: input["path"] }] : []
  691. case "bash":
  692. return []
  693. case "list":
  694. return input["path"] ? [{ path: input["path"] }] : []
  695. default:
  696. return []
  697. }
  698. }
  699. async function defaultModel(config: ACPConfig, cwd?: string) {
  700. const sdk = config.sdk
  701. const configured = config.defaultModel
  702. if (configured) return configured
  703. const model = await sdk.config
  704. .get({ throwOnError: true, query: { directory: cwd } })
  705. .then((resp) => {
  706. const cfg = resp.data
  707. if (!cfg.model) return undefined
  708. const parsed = Provider.parseModel(cfg.model)
  709. return {
  710. providerID: parsed.providerID,
  711. modelID: parsed.modelID,
  712. }
  713. })
  714. .catch((error) => {
  715. log.error("failed to load user config for default model", { error })
  716. return undefined
  717. })
  718. return model ?? { providerID: "opencode", modelID: "big-pickle" }
  719. }
  720. function parseUri(
  721. uri: string,
  722. ): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } {
  723. try {
  724. if (uri.startsWith("file://")) {
  725. const path = uri.slice(7)
  726. const name = path.split("/").pop() || path
  727. return {
  728. type: "file",
  729. url: uri,
  730. filename: name,
  731. mime: "text/plain",
  732. }
  733. }
  734. if (uri.startsWith("zed://")) {
  735. const url = new URL(uri)
  736. const path = url.searchParams.get("path")
  737. if (path) {
  738. const name = path.split("/").pop() || path
  739. return {
  740. type: "file",
  741. url: `file://${path}`,
  742. filename: name,
  743. mime: "text/plain",
  744. }
  745. }
  746. }
  747. return {
  748. type: "text",
  749. text: uri,
  750. }
  751. } catch {
  752. return {
  753. type: "text",
  754. text: uri,
  755. }
  756. }
  757. }
  758. }