mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-24 19:24:22 +01:00
rework acp to compensate for changes in Zed IDE (#4050)
This commit is contained in:
@@ -20,299 +20,321 @@ import {
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { Log } from "../util/log"
|
||||
import { ACPSessionManager } from "./session"
|
||||
import type { ACPConfig } from "./types"
|
||||
import type { ACPConfig, ACPSessionState } from "./types"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { SessionPrompt } from "../session/prompt"
|
||||
import { Installation } from "@/installation"
|
||||
import { SessionLock } from "@/session/lock"
|
||||
import { Bus } from "@/bus"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Command } from "@/command"
|
||||
import { Agent as Agents } from "@/agent/agent"
|
||||
import { Permission } from "@/permission"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
import { Config } from "@/config/config"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { z } from "zod"
|
||||
import { LoadAPIKeyError } from "ai"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk"
|
||||
|
||||
export namespace ACP {
|
||||
const log = Log.create({ service: "acp-agent" })
|
||||
|
||||
export async function init() {
|
||||
const model = await defaultModel({})
|
||||
export async function init({ sdk }: { sdk: OpencodeClient }) {
|
||||
const model = await defaultModel({ sdk })
|
||||
return {
|
||||
create: (connection: AgentSideConnection, config: ACPConfig) => {
|
||||
if (!config.defaultModel) {
|
||||
config.defaultModel = model
|
||||
create: (connection: AgentSideConnection, fullConfig: ACPConfig) => {
|
||||
if (!fullConfig.defaultModel) {
|
||||
fullConfig.defaultModel = model
|
||||
}
|
||||
return new Agent(connection, config)
|
||||
return new Agent(connection, fullConfig)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export class Agent implements ACPAgent {
|
||||
private sessionManager = new ACPSessionManager()
|
||||
private connection: AgentSideConnection
|
||||
private config: ACPConfig
|
||||
private sdk: OpencodeClient
|
||||
private sessionManager
|
||||
|
||||
constructor(connection: AgentSideConnection, config: ACPConfig = {}) {
|
||||
constructor(connection: AgentSideConnection, config: ACPConfig) {
|
||||
this.connection = connection
|
||||
this.config = config
|
||||
this.setupEventSubscriptions()
|
||||
this.sdk = config.sdk
|
||||
this.sessionManager = new ACPSessionManager(this.sdk)
|
||||
}
|
||||
|
||||
private setupEventSubscriptions() {
|
||||
private setupEventSubscriptions(session: ACPSessionState) {
|
||||
const sessionId = session.id
|
||||
const directory = session.cwd
|
||||
|
||||
const options: PermissionOption[] = [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
]
|
||||
Bus.subscribe(Permission.Event.Updated, async (event) => {
|
||||
const acpSession = this.sessionManager.get(event.properties.sessionID)
|
||||
if (!acpSession) return
|
||||
try {
|
||||
const permission = event.properties
|
||||
const res = await this.connection
|
||||
.requestPermission({
|
||||
sessionId: acpSession.id,
|
||||
toolCall: {
|
||||
toolCallId: permission.callID ?? permission.id,
|
||||
status: "pending",
|
||||
title: permission.title,
|
||||
rawInput: permission.metadata,
|
||||
kind: toToolKind(permission.type),
|
||||
locations: toLocations(permission.type, permission.metadata),
|
||||
},
|
||||
options,
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to request permission from ACP", {
|
||||
error,
|
||||
permissionID: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
})
|
||||
Permission.respond({
|
||||
sessionID: permission.sessionID,
|
||||
permissionID: permission.id,
|
||||
response: "reject",
|
||||
})
|
||||
return
|
||||
})
|
||||
if (!res) return
|
||||
if (res.outcome.outcome !== "selected") {
|
||||
Permission.respond({
|
||||
sessionID: permission.sessionID,
|
||||
permissionID: permission.id,
|
||||
response: "reject",
|
||||
})
|
||||
return
|
||||
}
|
||||
Permission.respond({
|
||||
sessionID: permission.sessionID,
|
||||
permissionID: permission.id,
|
||||
response: res.outcome.optionId as "once" | "always" | "reject",
|
||||
})
|
||||
} catch (err) {
|
||||
if (!(err instanceof Permission.RejectedError)) {
|
||||
log.error("unexpected error when handling permission", { error: err })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Bus.subscribe(MessageV2.Event.PartUpdated, async (event) => {
|
||||
const props = event.properties
|
||||
const { part } = props
|
||||
const acpSession = this.sessionManager.get(part.sessionID)
|
||||
if (!acpSession) return
|
||||
|
||||
const message = await Storage.read<MessageV2.Info>([
|
||||
"message",
|
||||
part.sessionID,
|
||||
part.messageID,
|
||||
]).catch(() => undefined)
|
||||
if (!message || message.role !== "assistant") return
|
||||
|
||||
if (part.type === "tool") {
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId: acpSession.id,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: part.callID,
|
||||
title: part.tool,
|
||||
kind: toToolKind(part.tool),
|
||||
status: "pending",
|
||||
locations: [],
|
||||
rawInput: {},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send tool pending to ACP", { error: err })
|
||||
})
|
||||
break
|
||||
case "running":
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId: acpSession.id,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "in_progress",
|
||||
locations: toLocations(part.tool, part.state.input),
|
||||
rawInput: part.state.input,
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send tool in_progress to ACP", { error: err })
|
||||
})
|
||||
break
|
||||
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: acpSession.id,
|
||||
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,
|
||||
}
|
||||
}),
|
||||
this.config.sdk.event.subscribe({ query: { directory } }).then(async (events) => {
|
||||
for await (const event of events.stream) {
|
||||
switch (event.type) {
|
||||
case "permission.updated":
|
||||
try {
|
||||
const permission = event.properties
|
||||
const res = await this.connection
|
||||
.requestPermission({
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: permission.callID ?? permission.id,
|
||||
status: "pending",
|
||||
title: permission.title,
|
||||
rawInput: permission.metadata,
|
||||
kind: toToolKind(permission.type),
|
||||
locations: toLocations(permission.type, permission.metadata),
|
||||
},
|
||||
options,
|
||||
})
|
||||
.catch(async (error) => {
|
||||
log.error("failed to request permission from ACP", {
|
||||
error,
|
||||
permissionID: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
})
|
||||
await this.config.sdk.postSessionIdPermissionsPermissionId({
|
||||
path: { id: permission.sessionID, permissionID: permission.id },
|
||||
body: {
|
||||
response: "reject",
|
||||
},
|
||||
query: { directory },
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send session update for todo", { error: err })
|
||||
})
|
||||
} else {
|
||||
log.error("failed to parse todo output", { error: parsedTodos.error })
|
||||
return
|
||||
})
|
||||
if (!res) return
|
||||
if (res.outcome.outcome !== "selected") {
|
||||
await this.config.sdk.postSessionIdPermissionsPermissionId({
|
||||
path: { id: permission.sessionID, permissionID: permission.id },
|
||||
body: {
|
||||
response: "reject",
|
||||
},
|
||||
query: { directory },
|
||||
})
|
||||
return
|
||||
}
|
||||
await this.config.sdk.postSessionIdPermissionsPermissionId({
|
||||
path: { id: permission.sessionID, permissionID: permission.id },
|
||||
body: {
|
||||
response: res.outcome.optionId as "once" | "always" | "reject",
|
||||
},
|
||||
query: { directory },
|
||||
})
|
||||
} catch (err) {
|
||||
log.error("unexpected error when handling permission", { error: err })
|
||||
} finally {
|
||||
break
|
||||
}
|
||||
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId: acpSession.id,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "completed",
|
||||
kind,
|
||||
content,
|
||||
title: part.state.title,
|
||||
rawOutput: {
|
||||
output: part.state.output,
|
||||
metadata: part.state.metadata,
|
||||
case "message.part.updated":
|
||||
log.info("message part updated", { event: event.properties })
|
||||
try {
|
||||
const props = event.properties
|
||||
const { part } = props
|
||||
|
||||
const message = await this.config.sdk.session
|
||||
.message({
|
||||
throwOnError: true,
|
||||
path: {
|
||||
id: part.sessionID,
|
||||
messageID: part.messageID,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send tool completed to ACP", { error: err })
|
||||
})
|
||||
break
|
||||
case "error":
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId: acpSession.id,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "failed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.error,
|
||||
query: { directory },
|
||||
})
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
log.error("unexpected error when fetching message", { error: err })
|
||||
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((err) => {
|
||||
log.error("failed to send tool pending to ACP", { error: err })
|
||||
})
|
||||
break
|
||||
case "running":
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "in_progress",
|
||||
locations: toLocations(part.tool, part.state.input),
|
||||
rawInput: part.state.input,
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send tool in_progress to ACP", { error: err })
|
||||
})
|
||||
break
|
||||
case "completed":
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.output,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
rawOutput: {
|
||||
error: part.state.error,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send tool error to ACP", { error: err })
|
||||
})
|
||||
break
|
||||
}
|
||||
} else if (part.type === "text") {
|
||||
const delta = props.delta
|
||||
if (delta && part.synthetic !== true) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId: acpSession.id,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: delta,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send text to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
} else if (part.type === "reasoning") {
|
||||
const delta = props.delta
|
||||
if (delta) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId: acpSession.id,
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: delta,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send reasoning to ACP", { error: err })
|
||||
})
|
||||
]
|
||||
|
||||
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((err) => {
|
||||
log.error("failed to send session update for todo", { error: err })
|
||||
})
|
||||
} 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,
|
||||
rawOutput: {
|
||||
output: part.state.output,
|
||||
metadata: part.state.metadata,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send tool completed to ACP", { error: err })
|
||||
})
|
||||
break
|
||||
case "error":
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "failed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.error,
|
||||
},
|
||||
},
|
||||
],
|
||||
rawOutput: {
|
||||
error: part.state.error,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send tool error to ACP", { error: err })
|
||||
})
|
||||
break
|
||||
}
|
||||
} else if (part.type === "text") {
|
||||
const delta = props.delta
|
||||
if (delta && part.synthetic !== true) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: delta,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send text to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
} else 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((err) => {
|
||||
log.error("failed to send reasoning to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -364,19 +386,26 @@ export namespace ACP {
|
||||
}
|
||||
|
||||
async newSession(params: NewSessionRequest) {
|
||||
const directory = params.cwd
|
||||
try {
|
||||
const model = await defaultModel(this.config)
|
||||
const session = await this.sessionManager.create(params.cwd, params.mcpServers, model)
|
||||
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 })
|
||||
|
||||
log.info("creating_session", { mcpServers: params.mcpServers.length })
|
||||
const load = await this.loadSession({
|
||||
cwd: params.cwd,
|
||||
cwd: directory,
|
||||
mcpServers: params.mcpServers,
|
||||
sessionId: session.id,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
this.setupEventSubscriptions(state)
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
sessionId,
|
||||
models: load.models,
|
||||
modes: load.modes,
|
||||
_meta: {},
|
||||
@@ -393,26 +422,47 @@ export namespace ACP {
|
||||
}
|
||||
|
||||
async loadSession(params: LoadSessionRequest) {
|
||||
const model = await defaultModel(this.config)
|
||||
const directory = params.cwd
|
||||
const model = await defaultModel(this.config, directory)
|
||||
const sessionId = params.sessionId
|
||||
|
||||
const providers = await Provider.list()
|
||||
const entries = Object.entries(providers).sort((a, b) => {
|
||||
const nameA = a[1].info.name.toLowerCase()
|
||||
const nameB = b[1].info.name.toLowerCase()
|
||||
const providers = await this.sdk.config
|
||||
.providers({ throwOnError: true, query: { directory } })
|
||||
.then((x) => x.data.providers)
|
||||
const entries = providers.sort((a, b) => {
|
||||
const nameA = a.name.toLowerCase()
|
||||
const nameB = b.name.toLowerCase()
|
||||
if (nameA < nameB) return -1
|
||||
if (nameA > nameB) return 1
|
||||
return 0
|
||||
})
|
||||
const availableModels = entries.flatMap(([providerID, provider]) => {
|
||||
const models = Provider.sort(Object.values(provider.info.models))
|
||||
const availableModels = entries.flatMap((provider) => {
|
||||
const models = Provider.sort(Object.values(provider.models))
|
||||
return models.map((model) => ({
|
||||
modelId: `${providerID}/${model.id}`,
|
||||
name: `${provider.info.name}/${model.name}`,
|
||||
modelId: `${provider.id}/${model.id}`,
|
||||
name: `${provider.name}/${model.name}`,
|
||||
}))
|
||||
})
|
||||
|
||||
const availableCommands = (await Command.list()).map((command) => ({
|
||||
const agents = await this.config.sdk.app
|
||||
.agents({
|
||||
throwOnError: true,
|
||||
query: {
|
||||
directory,
|
||||
},
|
||||
})
|
||||
.then((resp) => resp.data)
|
||||
|
||||
const commands = await this.config.sdk.command
|
||||
.list({
|
||||
throwOnError: true,
|
||||
query: {
|
||||
directory,
|
||||
},
|
||||
})
|
||||
.then((resp) => resp.data)
|
||||
|
||||
const availableCommands = commands.map((command) => ({
|
||||
name: command.name,
|
||||
description: command.description ?? "",
|
||||
}))
|
||||
@@ -423,7 +473,7 @@ export namespace ACP {
|
||||
description: "compact the session",
|
||||
})
|
||||
|
||||
const availableModes = (await Agents.list())
|
||||
const availableModes = agents
|
||||
.filter((agent) => agent.mode !== "subagent")
|
||||
.map((agent) => ({
|
||||
id: agent.name,
|
||||
@@ -459,7 +509,18 @@ export namespace ACP {
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(mcpServers).map(async ([key, mcp]) => {
|
||||
await MCP.add(key, mcp)
|
||||
await this.sdk.mcp
|
||||
.add({
|
||||
throwOnError: true,
|
||||
query: { directory },
|
||||
body: {
|
||||
name: key,
|
||||
config: mcp,
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to add mcp server", { name: key, error })
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -489,12 +550,8 @@ export namespace ACP {
|
||||
|
||||
async setSessionModel(params: SetSessionModelRequest) {
|
||||
const session = this.sessionManager.get(params.sessionId)
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`)
|
||||
}
|
||||
|
||||
const parsed = Provider.parseModel(params.modelId)
|
||||
const model = await Provider.getModel(parsed.providerID, parsed.modelID)
|
||||
const model = Provider.parseModel(params.modelId)
|
||||
|
||||
this.sessionManager.setModel(session.id, {
|
||||
providerID: model.providerID,
|
||||
@@ -507,31 +564,32 @@ export namespace ACP {
|
||||
}
|
||||
|
||||
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse | void> {
|
||||
const session = this.sessionManager.get(params.sessionId)
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`)
|
||||
}
|
||||
await Agents.get(params.modeId).then((agent) => {
|
||||
if (!agent) throw new Error(`Agent not found: ${params.modeId}`)
|
||||
})
|
||||
this.sessionManager.get(params.sessionId)
|
||||
await this.config.sdk.app
|
||||
.agents({ throwOnError: true })
|
||||
.then((x) => x.data)
|
||||
.then((agent) => {
|
||||
if (!agent) throw new Error(`Agent not found: ${params.modeId}`)
|
||||
})
|
||||
this.sessionManager.setMode(params.sessionId, params.modeId)
|
||||
}
|
||||
|
||||
async prompt(params: PromptRequest) {
|
||||
const sessionID = params.sessionId
|
||||
const acpSession = this.sessionManager.get(sessionID)
|
||||
if (!acpSession) {
|
||||
throw new Error(`Session not found: ${sessionID}`)
|
||||
}
|
||||
const session = this.sessionManager.get(sessionID)
|
||||
const directory = session.cwd
|
||||
|
||||
const current = acpSession.model
|
||||
const model = current ?? (await defaultModel(this.config))
|
||||
const current = session.model
|
||||
const model = current ?? (await defaultModel(this.config, directory))
|
||||
if (!current) {
|
||||
this.sessionManager.setModel(acpSession.id, model)
|
||||
this.sessionManager.setModel(session.id, model)
|
||||
}
|
||||
const agent = acpSession.modeId ?? "build"
|
||||
const agent = session.modeId ?? "build"
|
||||
|
||||
const parts: SessionPrompt.PromptInput["parts"] = []
|
||||
const parts: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "file"; url: string; filename: string; mime: string }
|
||||
> = []
|
||||
for (const part of params.prompt) {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
@@ -545,12 +603,14 @@ export namespace ACP {
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: `data:${part.mimeType};base64,${part.data}`,
|
||||
filename: "image",
|
||||
mime: part.mimeType,
|
||||
})
|
||||
} else if (part.uri && part.uri.startsWith("http:")) {
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: part.uri,
|
||||
filename: "image",
|
||||
mime: part.mimeType,
|
||||
})
|
||||
}
|
||||
@@ -581,7 +641,7 @@ export namespace ACP {
|
||||
|
||||
const cmd = (() => {
|
||||
const text = parts
|
||||
.filter((p) => p.type === "text")
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("")
|
||||
.trim()
|
||||
@@ -598,36 +658,50 @@ export namespace ACP {
|
||||
}
|
||||
|
||||
if (!cmd) {
|
||||
await SessionPrompt.prompt({
|
||||
sessionID,
|
||||
model: {
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
await this.sdk.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
model: {
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
},
|
||||
parts,
|
||||
agent,
|
||||
},
|
||||
query: {
|
||||
directory,
|
||||
},
|
||||
parts,
|
||||
agent,
|
||||
})
|
||||
return done
|
||||
}
|
||||
|
||||
const command = await Command.get(cmd.name)
|
||||
const command = await this.config.sdk.command
|
||||
.list({ throwOnError: true, query: { directory } })
|
||||
.then((x) => x.data.find((c) => c.name === cmd.name))
|
||||
if (command) {
|
||||
await SessionPrompt.command({
|
||||
sessionID,
|
||||
command: command.name,
|
||||
arguments: cmd.args,
|
||||
model: model.providerID + "/" + model.modelID,
|
||||
agent,
|
||||
await this.sdk.session.command({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
command: command.name,
|
||||
arguments: cmd.args,
|
||||
model: model.providerID + "/" + model.modelID,
|
||||
agent,
|
||||
},
|
||||
query: {
|
||||
directory,
|
||||
},
|
||||
})
|
||||
return done
|
||||
}
|
||||
|
||||
switch (cmd.name) {
|
||||
case "compact":
|
||||
await SessionCompaction.run({
|
||||
sessionID,
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
await this.config.sdk.session.summarize({
|
||||
path: { id: sessionID },
|
||||
throwOnError: true,
|
||||
query: {
|
||||
directory,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
@@ -636,7 +710,14 @@ export namespace ACP {
|
||||
}
|
||||
|
||||
async cancel(params: CancelNotification) {
|
||||
SessionLock.abort(params.sessionId)
|
||||
const session = this.sessionManager.get(params.sessionId)
|
||||
await this.config.sdk.session.abort({
|
||||
path: { id: params.sessionId },
|
||||
throwOnError: true,
|
||||
query: {
|
||||
directory: session.cwd,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,12 +768,15 @@ export namespace ACP {
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultModel(config: ACPConfig) {
|
||||
async function defaultModel(config: ACPConfig, cwd?: string) {
|
||||
const sdk = config.sdk
|
||||
const configured = config.defaultModel
|
||||
if (configured) return configured
|
||||
|
||||
const model = await Config.get()
|
||||
.then((cfg) => {
|
||||
const model = await sdk.config
|
||||
.get({ throwOnError: true, query: { directory: cwd } })
|
||||
.then((resp) => {
|
||||
const cfg = resp.data
|
||||
if (!cfg.model) return undefined
|
||||
const parsed = Provider.parseModel(cfg.model)
|
||||
return {
|
||||
|
||||
@@ -1,66 +1,74 @@
|
||||
import type { McpServer } from "@agentclientprotocol/sdk"
|
||||
import { Session } from "../session"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { RequestError, type McpServer } from "@agentclientprotocol/sdk"
|
||||
import type { ACPSessionState } from "./types"
|
||||
import { Log } from "@/util/log"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk"
|
||||
|
||||
const log = Log.create({ service: "acp-session-manager" })
|
||||
|
||||
export class ACPSessionManager {
|
||||
private sessions = new Map<string, ACPSessionState>()
|
||||
private sdk: OpencodeClient
|
||||
|
||||
constructor(sdk: OpencodeClient) {
|
||||
this.sdk = sdk
|
||||
}
|
||||
|
||||
async create(
|
||||
cwd: string,
|
||||
mcpServers: McpServer[],
|
||||
model?: ACPSessionState["model"],
|
||||
): Promise<ACPSessionState> {
|
||||
const session = await Session.create({ title: `ACP Session ${crypto.randomUUID()}` })
|
||||
const session = await this.sdk.session
|
||||
.create({
|
||||
body: {
|
||||
title: `ACP Session ${crypto.randomUUID()}`,
|
||||
},
|
||||
query: {
|
||||
directory: cwd,
|
||||
},
|
||||
throwOnError: true,
|
||||
})
|
||||
.then((x) => x.data)
|
||||
|
||||
const sessionId = session.id
|
||||
const resolvedModel = model ?? (await Provider.defaultModel())
|
||||
const resolvedModel = model
|
||||
|
||||
const state: ACPSessionState = {
|
||||
id: sessionId,
|
||||
parentId: session.parentID,
|
||||
cwd,
|
||||
mcpServers,
|
||||
createdAt: new Date(),
|
||||
model: resolvedModel,
|
||||
}
|
||||
log.info("creating_session", { state })
|
||||
|
||||
this.sessions.set(sessionId, state)
|
||||
return state
|
||||
}
|
||||
|
||||
get(sessionId: string) {
|
||||
return this.sessions.get(sessionId)
|
||||
}
|
||||
|
||||
async remove(sessionId: string) {
|
||||
const state = this.sessions.get(sessionId)
|
||||
if (!state) return
|
||||
|
||||
await Session.remove(sessionId).catch(() => {})
|
||||
this.sessions.delete(sessionId)
|
||||
}
|
||||
|
||||
has(sessionId: string) {
|
||||
return this.sessions.has(sessionId)
|
||||
get(sessionId: string): ACPSessionState {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
log.error("session not found", { sessionId })
|
||||
throw RequestError.invalidParams(JSON.stringify({ error: `Session not found: ${sessionId}` }))
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
getModel(sessionId: string) {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) return
|
||||
const session = this.get(sessionId)
|
||||
return session.model
|
||||
}
|
||||
|
||||
setModel(sessionId: string, model: ACPSessionState["model"]) {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) return
|
||||
const session = this.get(sessionId)
|
||||
session.model = model
|
||||
this.sessions.set(sessionId, session)
|
||||
return session
|
||||
}
|
||||
|
||||
setMode(sessionId: string, modeId: string) {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) return
|
||||
const session = this.get(sessionId)
|
||||
session.modeId = modeId
|
||||
this.sessions.set(sessionId, session)
|
||||
return session
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { McpServer } from "@agentclientprotocol/sdk"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk"
|
||||
|
||||
export interface ACPSessionState {
|
||||
id: string
|
||||
parentId?: string
|
||||
cwd: string
|
||||
mcpServers: McpServer[]
|
||||
createdAt: Date
|
||||
model: {
|
||||
model?: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export interface ACPSessionState {
|
||||
}
|
||||
|
||||
export interface ACPConfig {
|
||||
sdk: OpencodeClient
|
||||
defaultModel?: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
|
||||
@@ -3,6 +3,8 @@ import { bootstrap } from "../bootstrap"
|
||||
import { cmd } from "./cmd"
|
||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { ACP } from "@/acp/agent"
|
||||
import { Server } from "@/server/server"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
|
||||
const log = Log.create({ service: "acp-command" })
|
||||
|
||||
@@ -17,15 +19,34 @@ export const AcpCommand = cmd({
|
||||
command: "acp",
|
||||
describe: "Start ACP (Agent Client Protocol) server",
|
||||
builder: (yargs) => {
|
||||
return yargs.option("cwd", {
|
||||
describe: "working directory",
|
||||
type: "string",
|
||||
default: process.cwd(),
|
||||
})
|
||||
return yargs
|
||||
.option("cwd", {
|
||||
describe: "working directory",
|
||||
type: "string",
|
||||
default: process.cwd(),
|
||||
})
|
||||
.option("port", {
|
||||
type: "number",
|
||||
describe: "port to listen on",
|
||||
default: 0,
|
||||
})
|
||||
.option("hostname", {
|
||||
type: "string",
|
||||
describe: "hostname to listen on",
|
||||
default: "127.0.0.1",
|
||||
})
|
||||
},
|
||||
handler: async (opts) => {
|
||||
if (opts.cwd) process.chdir(opts["cwd"])
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const server = Server.listen({
|
||||
port: args.port,
|
||||
hostname: args.hostname,
|
||||
})
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
})
|
||||
|
||||
const input = new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
@@ -50,10 +71,10 @@ export const AcpCommand = cmd({
|
||||
})
|
||||
|
||||
const stream = ndJsonStream(input, output)
|
||||
const agent = await ACP.init()
|
||||
const agent = await ACP.init({ sdk })
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
return agent.create(conn, {})
|
||||
return agent.create(conn, { sdk })
|
||||
}, stream)
|
||||
|
||||
log.info("setup connection")
|
||||
|
||||
@@ -92,13 +92,28 @@ export namespace MCP {
|
||||
export async function add(name: string, mcp: Config.Mcp) {
|
||||
const s = await state()
|
||||
const result = await create(name, mcp)
|
||||
if (!result) return
|
||||
if (!result) {
|
||||
const status = {
|
||||
status: "failed" as const,
|
||||
error: "unknown error",
|
||||
}
|
||||
s.status[name] = status
|
||||
return {
|
||||
status,
|
||||
}
|
||||
}
|
||||
if (!result.mcpClient) {
|
||||
s.status[name] = result.status
|
||||
return
|
||||
return {
|
||||
status: s.status,
|
||||
}
|
||||
}
|
||||
s.clients[name] = result.mcpClient
|
||||
s.status[name] = result.status
|
||||
|
||||
return {
|
||||
status: s.status,
|
||||
}
|
||||
}
|
||||
|
||||
async function create(key: string, mcp: Config.Mcp) {
|
||||
@@ -207,8 +222,12 @@ export namespace MCP {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch(() => {})
|
||||
const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch((err) => {
|
||||
log.error("create() failed to get tools from client", { key, error: err })
|
||||
return undefined
|
||||
})
|
||||
if (!result) {
|
||||
log.info("create() tools() returned nothing, closing client", { key })
|
||||
await mcpClient.close().catch((error) => {
|
||||
log.error("Failed to close MCP client", {
|
||||
error,
|
||||
@@ -227,6 +246,7 @@ export namespace MCP {
|
||||
}
|
||||
}
|
||||
|
||||
log.info("create() successfully created client", { key, toolCount: Object.keys(result).length })
|
||||
return {
|
||||
mcpClient,
|
||||
status,
|
||||
@@ -238,13 +258,18 @@ export namespace MCP {
|
||||
}
|
||||
|
||||
export async function clients() {
|
||||
return state().then((state) => state.clients)
|
||||
const s = await state()
|
||||
log.info("clients() called", { clientCount: Object.keys(s.clients).length })
|
||||
return s.clients
|
||||
}
|
||||
|
||||
export async function tools() {
|
||||
const result: Record<string, Tool> = {}
|
||||
const s = await state()
|
||||
for (const [clientName, client] of Object.entries(await clients())) {
|
||||
log.info("tools() called", { clientCount: Object.keys(s.clients).length })
|
||||
const clientsSnapshot = await clients()
|
||||
for (const [clientName, client] of Object.entries(clientsSnapshot)) {
|
||||
log.info("tools() fetching tools for client", { clientName })
|
||||
const tools = await client.tools().catch((e) => {
|
||||
log.error("failed to get tools", { clientName, error: e.message })
|
||||
const failedStatus = {
|
||||
@@ -255,14 +280,17 @@ export namespace MCP {
|
||||
delete s.clients[clientName]
|
||||
})
|
||||
if (!tools) {
|
||||
log.info("tools() no tools returned for client", { clientName })
|
||||
continue
|
||||
}
|
||||
log.info("tools() got tools for client", { clientName, toolCount: Object.keys(tools).length })
|
||||
for (const [toolName, tool] of Object.entries(tools)) {
|
||||
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
const sanitizedToolName = toolName.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
result[sanitizedClientName + "_" + sanitizedToolName] = tool
|
||||
}
|
||||
}
|
||||
log.info("tools() final result", { toolCount: Object.keys(result).length })
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1359,6 +1359,36 @@ export namespace Server {
|
||||
return c.json(await MCP.status())
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/mcp",
|
||||
describeRoute({
|
||||
description: "Add MCP server dynamically",
|
||||
operationId: "mcp.add",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP server added successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.record(z.string(), MCP.Status)),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
name: z.string(),
|
||||
config: Config.Mcp,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { name, config } = c.req.valid("json")
|
||||
const result = await MCP.add(name, config)
|
||||
return c.json(result.status)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/lsp",
|
||||
describeRoute({
|
||||
|
||||
Reference in New Issue
Block a user