1567 lines · 51.4 KB · TypeScript
RawDownload
import {  RequestError,  type Agent as ACPAgent,  type AgentSideConnection,  type AuthenticateRequest,  type AuthMethod,  type CancelNotification,  type ForkSessionRequest,  type ForkSessionResponse,  type InitializeRequest,  type InitializeResponse,  type ListSessionsRequest,  type ListSessionsResponse,  type LoadSessionRequest,  type NewSessionRequest,  type PermissionOption,  type PlanEntry,  type PromptRequest,  type ResumeSessionRequest,  type ResumeSessionResponse,  type Role,  type SessionInfo,  type SetSessionModelRequest,  type SetSessionModeRequest,  type SetSessionModeResponse,  type ToolCallContent,  type ToolKind,} from "@agentclientprotocol/sdk" import { Log } from "../util/log"import { ACPSessionManager } from "./session"import type { ACPConfig } from "./types"import { Provider } from "../provider/provider"import { Agent as AgentModule } from "../agent/agent"import { Installation } from "@/installation"import { MessageV2 } from "@/session/message-v2"import { Config } from "@/config/config"import { Todo } from "@/session/todo"import { z } from "zod"import { LoadAPIKeyError } from "ai"import type { Event, OpenScienceClient, SessionMessageResponse } from "@synsci/sdk/v2"import { applyPatch } from "diff" type ModeOption = { id: string; name: string; description?: string }type ModelOption = { modelId: string; name: string } const DEFAULT_VARIANT_VALUE = "default" export namespace ACP {  const log = Log.create({ service: "acp-agent" })   export async function init({ sdk: _sdk }: { sdk: OpenScienceClient }) {    return {      create: (connection: AgentSideConnection, fullConfig: ACPConfig) => {        return new Agent(connection, fullConfig)      },    }  }   export class Agent implements ACPAgent {    private connection: AgentSideConnection    private config: ACPConfig    private sdk: OpenScienceClient    private sessionManager: ACPSessionManager    private eventAbort = new AbortController()    private eventStarted = false    private permissionQueues = new Map<string, Promise<void>>()    private permissionOptions: PermissionOption[] = [      { optionId: "once", kind: "allow_once", name: "Allow once" },      { optionId: "always", kind: "allow_always", name: "Always allow" },      { optionId: "reject", kind: "reject_once", name: "Reject" },    ]     constructor(connection: AgentSideConnection, config: ACPConfig) {      this.connection = connection      this.config = config      this.sdk = config.sdk      this.sessionManager = new ACPSessionManager(this.sdk)      this.startEventSubscription()    }     private startEventSubscription() {      if (this.eventStarted) return      this.eventStarted = true      this.runEventSubscription().catch((error) => {        if (this.eventAbort.signal.aborted) return        log.error("event subscription failed", { error })      })    }     private async runEventSubscription() {      while (true) {        if (this.eventAbort.signal.aborted) return        const events = await this.sdk.global.event({          signal: this.eventAbort.signal,        })        for await (const event of events.stream) {          if (this.eventAbort.signal.aborted) return          const payload = (event as any)?.payload          if (!payload) continue          await this.handleEvent(payload as Event).catch((error) => {            log.error("failed to handle event", { error, type: payload.type })          })        }      }    }     private async handleEvent(event: Event) {      switch (event.type) {        case "permission.asked": {          const permission = event.properties          const session = this.sessionManager.tryGet(permission.sessionID)          if (!session) return           const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve()          const next = prev            .then(async () => {              const directory = session.cwd               const res = await this.connection                .requestPermission({                  sessionId: permission.sessionID,                  toolCall: {                    toolCallId: permission.tool?.callID ?? permission.id,                    status: "pending",                    title: permission.permission,                    rawInput: permission.metadata,                    kind: toToolKind(permission.permission),                    locations: toLocations(permission.permission, permission.metadata),                  },                  options: this.permissionOptions,                })                .catch(async (error) => {                  log.error("failed to request permission from ACP", {                    error,                    permissionID: permission.id,                    sessionID: permission.sessionID,                  })                  await this.sdk.permission.reply({                    requestID: permission.id,                    reply: "reject",                    directory,                  })                  return undefined                })               if (!res) return              if (res.outcome.outcome !== "selected") {                await this.sdk.permission.reply({                  requestID: permission.id,                  reply: "reject",                  directory,                })                return              }               if (res.outcome.optionId !== "reject" && permission.permission == "edit") {                const metadata = permission.metadata || {}                const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""                const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""                 const content = await Bun.file(filepath).text()                const newContent = getNewContent(content, diff)                 if (newContent) {                  this.connection.writeTextFile({                    sessionId: session.id,                    path: filepath,                    content: newContent,                  })                }              }               await this.sdk.permission.reply({                requestID: permission.id,                reply: res.outcome.optionId as "once" | "always" | "reject",                directory,              })            })            .catch((error) => {              log.error("failed to handle permission", { error, permissionID: permission.id })            })            .finally(() => {              if (this.permissionQueues.get(permission.sessionID) === next) {                this.permissionQueues.delete(permission.sessionID)              }            })          this.permissionQueues.set(permission.sessionID, next)          return        }         case "message.part.updated": {          log.info("message part updated", { event: event.properties })          const props = event.properties          const part = props.part          const session = this.sessionManager.tryGet(part.sessionID)          if (!session) return          const sessionId = session.id          const directory = session.cwd           const message = await this.sdk.session            .message(              {                sessionID: part.sessionID,                messageID: part.messageID,                directory,              },              { throwOnError: true },            )            .then((x) => x.data)            .catch((error) => {              log.error("unexpected error when fetching message", { error })              return undefined            })           if (!message || message.info.role !== "assistant") return           if (part.type === "tool") {            switch (part.state.status) {              case "pending":                await this.connection                  .sessionUpdate({                    sessionId,                    update: {                      sessionUpdate: "tool_call",                      toolCallId: part.callID,                      title: part.tool,                      kind: toToolKind(part.tool),                      status: "pending",                      locations: [],                      rawInput: {},                    },                  })                  .catch((error) => {                    log.error("failed to send tool pending to ACP", { error })                  })                return               case "running":                await this.connection                  .sessionUpdate({                    sessionId,                    update: {                      sessionUpdate: "tool_call_update",                      toolCallId: part.callID,                      status: "in_progress",                      kind: toToolKind(part.tool),                      title: part.tool,                      locations: toLocations(part.tool, part.state.input),                      rawInput: part.state.input,                    },                  })                  .catch((error) => {                    log.error("failed to send tool in_progress to ACP", { error })                  })                return               case "completed": {                const kind = toToolKind(part.tool)                const content: ToolCallContent[] = [                  {                    type: "content",                    content: {                      type: "text",                      text: part.state.output,                    },                  },                ]                 if (kind === "edit") {                  const input = part.state.input                  const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""                  const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""                  const newText =                    typeof input["newString"] === "string"                      ? input["newString"]                      : typeof input["content"] === "string"                        ? input["content"]                        : ""                  content.push({                    type: "diff",                    path: filePath,                    oldText,                    newText,                  })                }                 if (part.tool === "todowrite") {                  const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))                  if (parsedTodos.success) {                    await this.connection                      .sessionUpdate({                        sessionId,                        update: {                          sessionUpdate: "plan",                          entries: parsedTodos.data.map((todo) => {                            const status: PlanEntry["status"] =                              todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])                            return {                              priority: "medium",                              status,                              content: todo.content,                            }                          }),                        },                      })                      .catch((error) => {                        log.error("failed to send session update for todo", { error })                      })                  } else {                    log.error("failed to parse todo output", { error: parsedTodos.error })                  }                }                 await this.connection                  .sessionUpdate({                    sessionId,                    update: {                      sessionUpdate: "tool_call_update",                      toolCallId: part.callID,                      status: "completed",                      kind,                      content,                      title: part.state.title,                      rawInput: part.state.input,                      rawOutput: {                        output: part.state.output,                        metadata: part.state.metadata,                      },                    },                  })                  .catch((error) => {                    log.error("failed to send tool completed to ACP", { error })                  })                return              }              case "error":                await this.connection                  .sessionUpdate({                    sessionId,                    update: {                      sessionUpdate: "tool_call_update",                      toolCallId: part.callID,                      status: "failed",                      kind: toToolKind(part.tool),                      title: part.tool,                      rawInput: part.state.input,                      content: [                        {                          type: "content",                          content: {                            type: "text",                            text: part.state.error,                          },                        },                      ],                      rawOutput: {                        error: part.state.error,                      },                    },                  })                  .catch((error) => {                    log.error("failed to send tool error to ACP", { error })                  })                return            }          }           if (part.type === "text") {            const delta = props.delta            if (delta && part.ignored !== true) {              await this.connection                .sessionUpdate({                  sessionId,                  update: {                    sessionUpdate: "agent_message_chunk",                    content: {                      type: "text",                      text: delta,                    },                  },                })                .catch((error) => {                  log.error("failed to send text to ACP", { error })                })            }            return          }           if (part.type === "reasoning") {            const delta = props.delta            if (delta) {              await this.connection                .sessionUpdate({                  sessionId,                  update: {                    sessionUpdate: "agent_thought_chunk",                    content: {                      type: "text",                      text: delta,                    },                  },                })                .catch((error) => {                  log.error("failed to send reasoning to ACP", { error })                })            }          }          return        }      }    }     async initialize(params: InitializeRequest): Promise<InitializeResponse> {      log.info("initialize", { protocolVersion: params.protocolVersion })       const authMethod: AuthMethod = {        description: "Run `openscience login` in the terminal",        name: "Login with OpenScience",        id: "openscience-login",      }       // If client supports terminal-auth capability, use that instead.      if (params.clientCapabilities?._meta?.["terminal-auth"] === true) {        authMethod._meta = {          "terminal-auth": {            command: "synsci",            args: ["connect", "login"],            label: "OpenScience Login",          },        }      }       return {        protocolVersion: 1,        agentCapabilities: {          loadSession: true,          mcpCapabilities: {            http: true,            sse: true,          },          promptCapabilities: {            embeddedContext: true,            image: true,          },          sessionCapabilities: {            fork: {},            list: {},            resume: {},          },        },        authMethods: [authMethod],        agentInfo: {          name: "OpenScience",          version: Installation.VERSION,        },      }    }     async authenticate(_params: AuthenticateRequest) {      throw new Error("Authentication not implemented")    }     async newSession(params: NewSessionRequest) {      const directory = params.cwd      try {        const model = await defaultModel(this.config, directory)         // Store ACP session state        const state = await this.sessionManager.create(params.cwd, params.mcpServers, model)        const sessionId = state.id         log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length })         const load = await this.loadSessionMode({          cwd: directory,          mcpServers: params.mcpServers,          sessionId,        })         return {          sessionId,          models: load.models,          modes: load.modes,          _meta: load._meta,        }      } catch (e) {        const error = MessageV2.fromError(e, {          providerID: this.config.defaultModel?.providerID ?? "unknown",        })        if (LoadAPIKeyError.isInstance(error)) {          throw RequestError.authRequired()        }        throw e      }    }     async loadSession(params: LoadSessionRequest) {      const directory = params.cwd      const sessionId = params.sessionId

Lines 1–500 of 1567.