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.sessionIdLines 1–500 of 1567. Load more
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