agent.ts 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127
  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 { Agent as AgentModule } from "../agent/agent"
  26. import { Installation } from "@/installation"
  27. import { MessageV2 } from "@/session/message-v2"
  28. import { Config } from "@/config/config"
  29. import { Todo } from "@/session/todo"
  30. import { z } from "zod"
  31. import { LoadAPIKeyError } from "ai"
  32. import type { OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
  33. import { applyPatch } from "diff"
  34. export namespace ACP {
  35. const log = Log.create({ service: "acp-agent" })
  36. export async function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
  37. return {
  38. create: (connection: AgentSideConnection, fullConfig: ACPConfig) => {
  39. return new Agent(connection, fullConfig)
  40. },
  41. }
  42. }
  43. export class Agent implements ACPAgent {
  44. private connection: AgentSideConnection
  45. private config: ACPConfig
  46. private sdk: OpencodeClient
  47. private sessionManager
  48. constructor(connection: AgentSideConnection, config: ACPConfig) {
  49. this.connection = connection
  50. this.config = config
  51. this.sdk = config.sdk
  52. this.sessionManager = new ACPSessionManager(this.sdk)
  53. }
  54. private setupEventSubscriptions(session: ACPSessionState) {
  55. const sessionId = session.id
  56. const directory = session.cwd
  57. const options: PermissionOption[] = [
  58. { optionId: "once", kind: "allow_once", name: "Allow once" },
  59. { optionId: "always", kind: "allow_always", name: "Always allow" },
  60. { optionId: "reject", kind: "reject_once", name: "Reject" },
  61. ]
  62. this.config.sdk.event.subscribe({ directory }).then(async (events) => {
  63. for await (const event of events.stream) {
  64. switch (event.type) {
  65. case "permission.asked":
  66. try {
  67. const permission = event.properties
  68. const res = await this.connection
  69. .requestPermission({
  70. sessionId,
  71. toolCall: {
  72. toolCallId: permission.tool?.callID ?? permission.id,
  73. status: "pending",
  74. title: permission.permission,
  75. rawInput: permission.metadata,
  76. kind: toToolKind(permission.permission),
  77. locations: toLocations(permission.permission, permission.metadata),
  78. },
  79. options,
  80. })
  81. .catch(async (error) => {
  82. log.error("failed to request permission from ACP", {
  83. error,
  84. permissionID: permission.id,
  85. sessionID: permission.sessionID,
  86. })
  87. await this.config.sdk.permission.reply({
  88. requestID: permission.id,
  89. reply: "reject",
  90. directory,
  91. })
  92. return
  93. })
  94. if (!res) return
  95. if (res.outcome.outcome !== "selected") {
  96. await this.config.sdk.permission.reply({
  97. requestID: permission.id,
  98. reply: "reject",
  99. directory,
  100. })
  101. return
  102. }
  103. if (res.outcome.optionId !== "reject" && permission.permission == "edit") {
  104. const metadata = permission.metadata || {}
  105. const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
  106. const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
  107. const content = await Bun.file(filepath).text()
  108. const newContent = getNewContent(content, diff)
  109. if (newContent) {
  110. this.connection.writeTextFile({
  111. sessionId: sessionId,
  112. path: filepath,
  113. content: newContent,
  114. })
  115. }
  116. }
  117. await this.config.sdk.permission.reply({
  118. requestID: permission.id,
  119. reply: res.outcome.optionId as "once" | "always" | "reject",
  120. directory,
  121. })
  122. } catch (err) {
  123. log.error("unexpected error when handling permission", { error: err })
  124. } finally {
  125. break
  126. }
  127. case "message.part.updated":
  128. log.info("message part updated", { event: event.properties })
  129. try {
  130. const props = event.properties
  131. const { part } = props
  132. const message = await this.config.sdk.session
  133. .message(
  134. {
  135. sessionID: part.sessionID,
  136. messageID: part.messageID,
  137. directory,
  138. },
  139. { throwOnError: true },
  140. )
  141. .then((x) => x.data)
  142. .catch((err) => {
  143. log.error("unexpected error when fetching message", { error: err })
  144. return undefined
  145. })
  146. if (!message || message.info.role !== "assistant") return
  147. if (part.type === "tool") {
  148. switch (part.state.status) {
  149. case "pending":
  150. await this.connection
  151. .sessionUpdate({
  152. sessionId,
  153. update: {
  154. sessionUpdate: "tool_call",
  155. toolCallId: part.callID,
  156. title: part.tool,
  157. kind: toToolKind(part.tool),
  158. status: "pending",
  159. locations: [],
  160. rawInput: {},
  161. },
  162. })
  163. .catch((err) => {
  164. log.error("failed to send tool pending to ACP", { error: err })
  165. })
  166. break
  167. case "running":
  168. await this.connection
  169. .sessionUpdate({
  170. sessionId,
  171. update: {
  172. sessionUpdate: "tool_call_update",
  173. toolCallId: part.callID,
  174. status: "in_progress",
  175. kind: toToolKind(part.tool),
  176. title: part.tool,
  177. locations: toLocations(part.tool, part.state.input),
  178. rawInput: part.state.input,
  179. },
  180. })
  181. .catch((err) => {
  182. log.error("failed to send tool in_progress to ACP", { error: err })
  183. })
  184. break
  185. case "completed":
  186. const kind = toToolKind(part.tool)
  187. const content: ToolCallContent[] = [
  188. {
  189. type: "content",
  190. content: {
  191. type: "text",
  192. text: part.state.output,
  193. },
  194. },
  195. ]
  196. if (kind === "edit") {
  197. const input = part.state.input
  198. const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
  199. const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
  200. const newText =
  201. typeof input["newString"] === "string"
  202. ? input["newString"]
  203. : typeof input["content"] === "string"
  204. ? input["content"]
  205. : ""
  206. content.push({
  207. type: "diff",
  208. path: filePath,
  209. oldText,
  210. newText,
  211. })
  212. }
  213. if (part.tool === "todowrite") {
  214. const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
  215. if (parsedTodos.success) {
  216. await this.connection
  217. .sessionUpdate({
  218. sessionId,
  219. update: {
  220. sessionUpdate: "plan",
  221. entries: parsedTodos.data.map((todo) => {
  222. const status: PlanEntry["status"] =
  223. todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
  224. return {
  225. priority: "medium",
  226. status,
  227. content: todo.content,
  228. }
  229. }),
  230. },
  231. })
  232. .catch((err) => {
  233. log.error("failed to send session update for todo", { error: err })
  234. })
  235. } else {
  236. log.error("failed to parse todo output", { error: parsedTodos.error })
  237. }
  238. }
  239. await this.connection
  240. .sessionUpdate({
  241. sessionId,
  242. update: {
  243. sessionUpdate: "tool_call_update",
  244. toolCallId: part.callID,
  245. status: "completed",
  246. kind,
  247. content,
  248. title: part.state.title,
  249. rawInput: part.state.input,
  250. rawOutput: {
  251. output: part.state.output,
  252. metadata: part.state.metadata,
  253. },
  254. },
  255. })
  256. .catch((err) => {
  257. log.error("failed to send tool completed to ACP", { error: err })
  258. })
  259. break
  260. case "error":
  261. await this.connection
  262. .sessionUpdate({
  263. sessionId,
  264. update: {
  265. sessionUpdate: "tool_call_update",
  266. toolCallId: part.callID,
  267. status: "failed",
  268. kind: toToolKind(part.tool),
  269. title: part.tool,
  270. rawInput: part.state.input,
  271. content: [
  272. {
  273. type: "content",
  274. content: {
  275. type: "text",
  276. text: part.state.error,
  277. },
  278. },
  279. ],
  280. rawOutput: {
  281. error: part.state.error,
  282. },
  283. },
  284. })
  285. .catch((err) => {
  286. log.error("failed to send tool error to ACP", { error: err })
  287. })
  288. break
  289. }
  290. } else if (part.type === "text") {
  291. const delta = props.delta
  292. if (delta && part.synthetic !== true) {
  293. await this.connection
  294. .sessionUpdate({
  295. sessionId,
  296. update: {
  297. sessionUpdate: "agent_message_chunk",
  298. content: {
  299. type: "text",
  300. text: delta,
  301. },
  302. },
  303. })
  304. .catch((err) => {
  305. log.error("failed to send text to ACP", { error: err })
  306. })
  307. }
  308. } else if (part.type === "reasoning") {
  309. const delta = props.delta
  310. if (delta) {
  311. await this.connection
  312. .sessionUpdate({
  313. sessionId,
  314. update: {
  315. sessionUpdate: "agent_thought_chunk",
  316. content: {
  317. type: "text",
  318. text: delta,
  319. },
  320. },
  321. })
  322. .catch((err) => {
  323. log.error("failed to send reasoning to ACP", { error: err })
  324. })
  325. }
  326. }
  327. } finally {
  328. break
  329. }
  330. }
  331. }
  332. })
  333. }
  334. async initialize(params: InitializeRequest): Promise<InitializeResponse> {
  335. log.info("initialize", { protocolVersion: params.protocolVersion })
  336. const authMethod: AuthMethod = {
  337. description: "Run `opencode auth login` in the terminal",
  338. name: "Login with opencode",
  339. id: "opencode-login",
  340. }
  341. // If client supports terminal-auth capability, use that instead.
  342. if (params.clientCapabilities?._meta?.["terminal-auth"] === true) {
  343. authMethod._meta = {
  344. "terminal-auth": {
  345. command: "opencode",
  346. args: ["auth", "login"],
  347. label: "OpenCode Login",
  348. },
  349. }
  350. }
  351. return {
  352. protocolVersion: 1,
  353. agentCapabilities: {
  354. loadSession: true,
  355. mcpCapabilities: {
  356. http: true,
  357. sse: true,
  358. },
  359. promptCapabilities: {
  360. embeddedContext: true,
  361. image: true,
  362. },
  363. },
  364. authMethods: [authMethod],
  365. agentInfo: {
  366. name: "OpenCode",
  367. version: Installation.VERSION,
  368. },
  369. }
  370. }
  371. async authenticate(_params: AuthenticateRequest) {
  372. throw new Error("Authentication not implemented")
  373. }
  374. async newSession(params: NewSessionRequest) {
  375. const directory = params.cwd
  376. try {
  377. const model = await defaultModel(this.config, directory)
  378. // Store ACP session state
  379. const state = await this.sessionManager.create(params.cwd, params.mcpServers, model)
  380. const sessionId = state.id
  381. log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length })
  382. const load = await this.loadSessionMode({
  383. cwd: directory,
  384. mcpServers: params.mcpServers,
  385. sessionId,
  386. })
  387. this.setupEventSubscriptions(state)
  388. return {
  389. sessionId,
  390. models: load.models,
  391. modes: load.modes,
  392. _meta: {},
  393. }
  394. } catch (e) {
  395. const error = MessageV2.fromError(e, {
  396. providerID: this.config.defaultModel?.providerID ?? "unknown",
  397. })
  398. if (LoadAPIKeyError.isInstance(error)) {
  399. throw RequestError.authRequired()
  400. }
  401. throw e
  402. }
  403. }
  404. async loadSession(params: LoadSessionRequest) {
  405. const directory = params.cwd
  406. const sessionId = params.sessionId
  407. try {
  408. const model = await defaultModel(this.config, directory)
  409. // Store ACP session state
  410. const state = await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model)
  411. log.info("load_session", { sessionId, mcpServers: params.mcpServers.length })
  412. const mode = await this.loadSessionMode({
  413. cwd: directory,
  414. mcpServers: params.mcpServers,
  415. sessionId,
  416. })
  417. this.setupEventSubscriptions(state)
  418. // Replay session history
  419. const messages = await this.sdk.session
  420. .messages(
  421. {
  422. sessionID: sessionId,
  423. directory,
  424. },
  425. { throwOnError: true },
  426. )
  427. .then((x) => x.data)
  428. .catch((err) => {
  429. log.error("unexpected error when fetching message", { error: err })
  430. return undefined
  431. })
  432. for (const msg of messages ?? []) {
  433. log.debug("replay message", msg)
  434. await this.processMessage(msg)
  435. }
  436. return mode
  437. } catch (e) {
  438. const error = MessageV2.fromError(e, {
  439. providerID: this.config.defaultModel?.providerID ?? "unknown",
  440. })
  441. if (LoadAPIKeyError.isInstance(error)) {
  442. throw RequestError.authRequired()
  443. }
  444. throw e
  445. }
  446. }
  447. private async processMessage(message: SessionMessageResponse) {
  448. log.debug("process message", message)
  449. if (message.info.role !== "assistant" && message.info.role !== "user") return
  450. const sessionId = message.info.sessionID
  451. for (const part of message.parts) {
  452. if (part.type === "tool") {
  453. switch (part.state.status) {
  454. case "pending":
  455. await this.connection
  456. .sessionUpdate({
  457. sessionId,
  458. update: {
  459. sessionUpdate: "tool_call",
  460. toolCallId: part.callID,
  461. title: part.tool,
  462. kind: toToolKind(part.tool),
  463. status: "pending",
  464. locations: [],
  465. rawInput: {},
  466. },
  467. })
  468. .catch((err) => {
  469. log.error("failed to send tool pending to ACP", { error: err })
  470. })
  471. break
  472. case "running":
  473. await this.connection
  474. .sessionUpdate({
  475. sessionId,
  476. update: {
  477. sessionUpdate: "tool_call_update",
  478. toolCallId: part.callID,
  479. status: "in_progress",
  480. kind: toToolKind(part.tool),
  481. title: part.tool,
  482. locations: toLocations(part.tool, part.state.input),
  483. rawInput: part.state.input,
  484. },
  485. })
  486. .catch((err) => {
  487. log.error("failed to send tool in_progress to ACP", { error: err })
  488. })
  489. break
  490. case "completed":
  491. const kind = toToolKind(part.tool)
  492. const content: ToolCallContent[] = [
  493. {
  494. type: "content",
  495. content: {
  496. type: "text",
  497. text: part.state.output,
  498. },
  499. },
  500. ]
  501. if (kind === "edit") {
  502. const input = part.state.input
  503. const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
  504. const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
  505. const newText =
  506. typeof input["newString"] === "string"
  507. ? input["newString"]
  508. : typeof input["content"] === "string"
  509. ? input["content"]
  510. : ""
  511. content.push({
  512. type: "diff",
  513. path: filePath,
  514. oldText,
  515. newText,
  516. })
  517. }
  518. if (part.tool === "todowrite") {
  519. const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
  520. if (parsedTodos.success) {
  521. await this.connection
  522. .sessionUpdate({
  523. sessionId,
  524. update: {
  525. sessionUpdate: "plan",
  526. entries: parsedTodos.data.map((todo) => {
  527. const status: PlanEntry["status"] =
  528. todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
  529. return {
  530. priority: "medium",
  531. status,
  532. content: todo.content,
  533. }
  534. }),
  535. },
  536. })
  537. .catch((err) => {
  538. log.error("failed to send session update for todo", { error: err })
  539. })
  540. } else {
  541. log.error("failed to parse todo output", { error: parsedTodos.error })
  542. }
  543. }
  544. await this.connection
  545. .sessionUpdate({
  546. sessionId,
  547. update: {
  548. sessionUpdate: "tool_call_update",
  549. toolCallId: part.callID,
  550. status: "completed",
  551. kind,
  552. content,
  553. title: part.state.title,
  554. rawInput: part.state.input,
  555. rawOutput: {
  556. output: part.state.output,
  557. metadata: part.state.metadata,
  558. },
  559. },
  560. })
  561. .catch((err) => {
  562. log.error("failed to send tool completed to ACP", { error: err })
  563. })
  564. break
  565. case "error":
  566. await this.connection
  567. .sessionUpdate({
  568. sessionId,
  569. update: {
  570. sessionUpdate: "tool_call_update",
  571. toolCallId: part.callID,
  572. status: "failed",
  573. kind: toToolKind(part.tool),
  574. title: part.tool,
  575. rawInput: part.state.input,
  576. content: [
  577. {
  578. type: "content",
  579. content: {
  580. type: "text",
  581. text: part.state.error,
  582. },
  583. },
  584. ],
  585. rawOutput: {
  586. error: part.state.error,
  587. },
  588. },
  589. })
  590. .catch((err) => {
  591. log.error("failed to send tool error to ACP", { error: err })
  592. })
  593. break
  594. }
  595. } else if (part.type === "text") {
  596. if (part.text) {
  597. await this.connection
  598. .sessionUpdate({
  599. sessionId,
  600. update: {
  601. sessionUpdate: message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk",
  602. content: {
  603. type: "text",
  604. text: part.text,
  605. },
  606. },
  607. })
  608. .catch((err) => {
  609. log.error("failed to send text to ACP", { error: err })
  610. })
  611. }
  612. } else if (part.type === "reasoning") {
  613. if (part.text) {
  614. await this.connection
  615. .sessionUpdate({
  616. sessionId,
  617. update: {
  618. sessionUpdate: "agent_thought_chunk",
  619. content: {
  620. type: "text",
  621. text: part.text,
  622. },
  623. },
  624. })
  625. .catch((err) => {
  626. log.error("failed to send reasoning to ACP", { error: err })
  627. })
  628. }
  629. }
  630. }
  631. }
  632. private async loadSessionMode(params: LoadSessionRequest) {
  633. const directory = params.cwd
  634. const model = await defaultModel(this.config, directory)
  635. const sessionId = params.sessionId
  636. const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers)
  637. const entries = providers.sort((a, b) => {
  638. const nameA = a.name.toLowerCase()
  639. const nameB = b.name.toLowerCase()
  640. if (nameA < nameB) return -1
  641. if (nameA > nameB) return 1
  642. return 0
  643. })
  644. const availableModels = entries.flatMap((provider) => {
  645. const models = Provider.sort(Object.values(provider.models))
  646. return models.map((model) => ({
  647. modelId: `${provider.id}/${model.id}`,
  648. name: `${provider.name}/${model.name}`,
  649. }))
  650. })
  651. const agents = await this.config.sdk.app
  652. .agents(
  653. {
  654. directory,
  655. },
  656. { throwOnError: true },
  657. )
  658. .then((resp) => resp.data!)
  659. const commands = await this.config.sdk.command
  660. .list(
  661. {
  662. directory,
  663. },
  664. { throwOnError: true },
  665. )
  666. .then((resp) => resp.data!)
  667. const availableCommands = commands.map((command) => ({
  668. name: command.name,
  669. description: command.description ?? "",
  670. }))
  671. const names = new Set(availableCommands.map((c) => c.name))
  672. if (!names.has("compact"))
  673. availableCommands.push({
  674. name: "compact",
  675. description: "compact the session",
  676. })
  677. const availableModes = agents
  678. .filter((agent) => agent.mode !== "subagent" && !agent.hidden)
  679. .map((agent) => ({
  680. id: agent.name,
  681. name: agent.name,
  682. description: agent.description,
  683. }))
  684. const defaultAgentName = await AgentModule.defaultAgent()
  685. const currentModeId = availableModes.find((m) => m.name === defaultAgentName)?.id ?? availableModes[0].id
  686. // Persist the default mode so prompt() uses it immediately
  687. this.sessionManager.setMode(sessionId, currentModeId)
  688. const mcpServers: Record<string, Config.Mcp> = {}
  689. for (const server of params.mcpServers) {
  690. if ("type" in server) {
  691. mcpServers[server.name] = {
  692. url: server.url,
  693. headers: server.headers.reduce<Record<string, string>>((acc, { name, value }) => {
  694. acc[name] = value
  695. return acc
  696. }, {}),
  697. type: "remote",
  698. }
  699. } else {
  700. mcpServers[server.name] = {
  701. type: "local",
  702. command: [server.command, ...server.args],
  703. environment: server.env.reduce<Record<string, string>>((acc, { name, value }) => {
  704. acc[name] = value
  705. return acc
  706. }, {}),
  707. }
  708. }
  709. }
  710. await Promise.all(
  711. Object.entries(mcpServers).map(async ([key, mcp]) => {
  712. await this.sdk.mcp
  713. .add(
  714. {
  715. directory,
  716. name: key,
  717. config: mcp,
  718. },
  719. { throwOnError: true },
  720. )
  721. .catch((error) => {
  722. log.error("failed to add mcp server", { name: key, error })
  723. })
  724. }),
  725. )
  726. setTimeout(() => {
  727. this.connection.sessionUpdate({
  728. sessionId,
  729. update: {
  730. sessionUpdate: "available_commands_update",
  731. availableCommands,
  732. },
  733. })
  734. }, 0)
  735. return {
  736. sessionId,
  737. models: {
  738. currentModelId: `${model.providerID}/${model.modelID}`,
  739. availableModels,
  740. },
  741. modes: {
  742. availableModes,
  743. currentModeId,
  744. },
  745. _meta: {},
  746. }
  747. }
  748. async setSessionModel(params: SetSessionModelRequest) {
  749. const session = this.sessionManager.get(params.sessionId)
  750. const model = Provider.parseModel(params.modelId)
  751. this.sessionManager.setModel(session.id, {
  752. providerID: model.providerID,
  753. modelID: model.modelID,
  754. })
  755. return {
  756. _meta: {},
  757. }
  758. }
  759. async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse | void> {
  760. this.sessionManager.get(params.sessionId)
  761. await this.config.sdk.app
  762. .agents({}, { throwOnError: true })
  763. .then((x) => x.data)
  764. .then((agent) => {
  765. if (!agent) throw new Error(`Agent not found: ${params.modeId}`)
  766. })
  767. this.sessionManager.setMode(params.sessionId, params.modeId)
  768. }
  769. async prompt(params: PromptRequest) {
  770. const sessionID = params.sessionId
  771. const session = this.sessionManager.get(sessionID)
  772. const directory = session.cwd
  773. const current = session.model
  774. const model = current ?? (await defaultModel(this.config, directory))
  775. if (!current) {
  776. this.sessionManager.setModel(session.id, model)
  777. }
  778. const agent = session.modeId ?? (await AgentModule.defaultAgent())
  779. const parts: Array<
  780. { type: "text"; text: string } | { type: "file"; url: string; filename: string; mime: string }
  781. > = []
  782. for (const part of params.prompt) {
  783. switch (part.type) {
  784. case "text":
  785. parts.push({
  786. type: "text" as const,
  787. text: part.text,
  788. })
  789. break
  790. case "image":
  791. if (part.data) {
  792. parts.push({
  793. type: "file",
  794. url: `data:${part.mimeType};base64,${part.data}`,
  795. filename: "image",
  796. mime: part.mimeType,
  797. })
  798. } else if (part.uri && part.uri.startsWith("http:")) {
  799. parts.push({
  800. type: "file",
  801. url: part.uri,
  802. filename: "image",
  803. mime: part.mimeType,
  804. })
  805. }
  806. break
  807. case "resource_link":
  808. const parsed = parseUri(part.uri)
  809. parts.push(parsed)
  810. break
  811. case "resource":
  812. const resource = part.resource
  813. if ("text" in resource) {
  814. parts.push({
  815. type: "text",
  816. text: resource.text,
  817. })
  818. }
  819. break
  820. default:
  821. break
  822. }
  823. }
  824. log.info("parts", { parts })
  825. const cmd = (() => {
  826. const text = parts
  827. .filter((p): p is { type: "text"; text: string } => p.type === "text")
  828. .map((p) => p.text)
  829. .join("")
  830. .trim()
  831. if (!text.startsWith("/")) return
  832. const [name, ...rest] = text.slice(1).split(/\s+/)
  833. return { name, args: rest.join(" ").trim() }
  834. })()
  835. const done = {
  836. stopReason: "end_turn" as const,
  837. _meta: {},
  838. }
  839. if (!cmd) {
  840. await this.sdk.session.prompt({
  841. sessionID,
  842. model: {
  843. providerID: model.providerID,
  844. modelID: model.modelID,
  845. },
  846. parts,
  847. agent,
  848. directory,
  849. })
  850. return done
  851. }
  852. const command = await this.config.sdk.command
  853. .list({ directory }, { throwOnError: true })
  854. .then((x) => x.data!.find((c) => c.name === cmd.name))
  855. if (command) {
  856. await this.sdk.session.command({
  857. sessionID,
  858. command: command.name,
  859. arguments: cmd.args,
  860. model: model.providerID + "/" + model.modelID,
  861. agent,
  862. directory,
  863. })
  864. return done
  865. }
  866. switch (cmd.name) {
  867. case "compact":
  868. await this.config.sdk.session.summarize(
  869. {
  870. sessionID,
  871. directory,
  872. providerID: model.providerID,
  873. modelID: model.modelID,
  874. },
  875. { throwOnError: true },
  876. )
  877. break
  878. }
  879. return done
  880. }
  881. async cancel(params: CancelNotification) {
  882. const session = this.sessionManager.get(params.sessionId)
  883. await this.config.sdk.session.abort(
  884. {
  885. sessionID: params.sessionId,
  886. directory: session.cwd,
  887. },
  888. { throwOnError: true },
  889. )
  890. }
  891. }
  892. function toToolKind(toolName: string): ToolKind {
  893. const tool = toolName.toLocaleLowerCase()
  894. switch (tool) {
  895. case "bash":
  896. return "execute"
  897. case "webfetch":
  898. return "fetch"
  899. case "edit":
  900. case "patch":
  901. case "write":
  902. return "edit"
  903. case "grep":
  904. case "glob":
  905. case "context7_resolve_library_id":
  906. case "context7_get_library_docs":
  907. return "search"
  908. case "list":
  909. case "read":
  910. return "read"
  911. default:
  912. return "other"
  913. }
  914. }
  915. function toLocations(toolName: string, input: Record<string, any>): { path: string }[] {
  916. const tool = toolName.toLocaleLowerCase()
  917. switch (tool) {
  918. case "read":
  919. case "edit":
  920. case "write":
  921. return input["filePath"] ? [{ path: input["filePath"] }] : []
  922. case "glob":
  923. case "grep":
  924. return input["path"] ? [{ path: input["path"] }] : []
  925. case "bash":
  926. return []
  927. case "list":
  928. return input["path"] ? [{ path: input["path"] }] : []
  929. default:
  930. return []
  931. }
  932. }
  933. async function defaultModel(config: ACPConfig, cwd?: string) {
  934. const sdk = config.sdk
  935. const configured = config.defaultModel
  936. if (configured) return configured
  937. const directory = cwd ?? process.cwd()
  938. const specified = await sdk.config
  939. .get({ directory }, { throwOnError: true })
  940. .then((resp) => {
  941. const cfg = resp.data
  942. if (!cfg || !cfg.model) return undefined
  943. const parsed = Provider.parseModel(cfg.model)
  944. return {
  945. providerID: parsed.providerID,
  946. modelID: parsed.modelID,
  947. }
  948. })
  949. .catch((error) => {
  950. log.error("failed to load user config for default model", { error })
  951. return undefined
  952. })
  953. const providers = await sdk.config
  954. .providers({ directory }, { throwOnError: true })
  955. .then((x) => x.data?.providers ?? [])
  956. .catch((error) => {
  957. log.error("failed to list providers for default model", { error })
  958. return []
  959. })
  960. if (specified && providers.length) {
  961. const provider = providers.find((p) => p.id === specified.providerID)
  962. if (provider && provider.models[specified.modelID]) return specified
  963. }
  964. if (specified && !providers.length) return specified
  965. const opencodeProvider = providers.find((p) => p.id === "opencode")
  966. if (opencodeProvider) {
  967. if (opencodeProvider.models["big-pickle"]) {
  968. return { providerID: "opencode", modelID: "big-pickle" }
  969. }
  970. const [best] = Provider.sort(Object.values(opencodeProvider.models))
  971. if (best) {
  972. return {
  973. providerID: best.providerID,
  974. modelID: best.id,
  975. }
  976. }
  977. }
  978. const models = providers.flatMap((p) => Object.values(p.models))
  979. const [best] = Provider.sort(models)
  980. if (best) {
  981. return {
  982. providerID: best.providerID,
  983. modelID: best.id,
  984. }
  985. }
  986. if (specified) return specified
  987. return { providerID: "opencode", modelID: "big-pickle" }
  988. }
  989. function parseUri(
  990. uri: string,
  991. ): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } {
  992. try {
  993. if (uri.startsWith("file://")) {
  994. const path = uri.slice(7)
  995. const name = path.split("/").pop() || path
  996. return {
  997. type: "file",
  998. url: uri,
  999. filename: name,
  1000. mime: "text/plain",
  1001. }
  1002. }
  1003. if (uri.startsWith("zed://")) {
  1004. const url = new URL(uri)
  1005. const path = url.searchParams.get("path")
  1006. if (path) {
  1007. const name = path.split("/").pop() || path
  1008. return {
  1009. type: "file",
  1010. url: `file://${path}`,
  1011. filename: name,
  1012. mime: "text/plain",
  1013. }
  1014. }
  1015. }
  1016. return {
  1017. type: "text",
  1018. text: uri,
  1019. }
  1020. } catch {
  1021. return {
  1022. type: "text",
  1023. text: uri,
  1024. }
  1025. }
  1026. }
  1027. function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined {
  1028. const result = applyPatch(fileOriginal, unifiedDiff)
  1029. if (result === false) {
  1030. log.error("Failed to apply unified diff (context mismatch)")
  1031. return undefined
  1032. }
  1033. return result
  1034. }
  1035. }