mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-05 16:36:52 +00:00
feat: Add ACP (Agent Client Protocol) support (#2947)
Co-authored-by: opencode-bot <devnull@opencode.local> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -95,6 +95,16 @@ export namespace SessionPrompt {
|
||||
agent: z.string().optional(),
|
||||
system: z.string().optional(),
|
||||
tools: z.record(z.string(), z.boolean()).optional(),
|
||||
/**
|
||||
* ACP (Agent Client Protocol) connection details for streaming responses.
|
||||
* When provided, enables real-time streaming and tool execution visibility.
|
||||
*/
|
||||
acpConnection: z
|
||||
.object({
|
||||
connection: z.any(), // AgentSideConnection - using any to avoid circular deps
|
||||
sessionId: z.string(), // ACP session ID (different from opencode sessionID)
|
||||
})
|
||||
.optional(),
|
||||
parts: z.array(
|
||||
z.discriminatedUnion("type", [
|
||||
MessageV2.TextPart.omit({
|
||||
@@ -173,6 +183,7 @@ export namespace SessionPrompt {
|
||||
agent: agent.name,
|
||||
system,
|
||||
abort: abort.signal,
|
||||
acpConnection: input.acpConnection,
|
||||
})
|
||||
|
||||
const tools = await resolveTools({
|
||||
@@ -820,6 +831,60 @@ export namespace SessionPrompt {
|
||||
return input.messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps tool names to ACP tool kinds for consistent categorization.
|
||||
* - read: Tools that read data (read, glob, grep, list, webfetch, docs)
|
||||
* - edit: Tools that modify state (edit, write, bash)
|
||||
* - other: All other tools (MCP tools, task, todowrite, etc.)
|
||||
*/
|
||||
function determineToolKind(toolName: string): "read" | "edit" | "other" {
|
||||
const readTools = [
|
||||
"read",
|
||||
"glob",
|
||||
"grep",
|
||||
"list",
|
||||
"webfetch",
|
||||
"context7_resolve_library_id",
|
||||
"context7_get_library_docs",
|
||||
]
|
||||
const editTools = ["edit", "write", "bash"]
|
||||
|
||||
if (readTools.includes(toolName.toLowerCase())) return "read"
|
||||
if (editTools.includes(toolName.toLowerCase())) return "edit"
|
||||
return "other"
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts file/directory locations from tool inputs for ACP notifications.
|
||||
* Returns array of {path} objects that ACP clients can use for navigation.
|
||||
*
|
||||
* Examples:
|
||||
* - read({filePath: "/foo/bar.ts"}) -> [{path: "/foo/bar.ts"}]
|
||||
* - glob({pattern: "*.ts", path: "/src"}) -> [{path: "/src"}]
|
||||
* - bash({command: "ls"}) -> [] (no file references)
|
||||
*/
|
||||
function extractLocations(toolName: string, input: Record<string, any>): { path: string }[] {
|
||||
try {
|
||||
switch (toolName.toLowerCase()) {
|
||||
case "read":
|
||||
case "edit":
|
||||
case "write":
|
||||
return input["filePath"] ? [{ path: input["filePath"] }] : []
|
||||
case "glob":
|
||||
case "grep":
|
||||
return input["path"] ? [{ path: input["path"] }] : []
|
||||
case "bash":
|
||||
return []
|
||||
case "list":
|
||||
return input["path"] ? [{ path: input["path"] }] : []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export type Processor = Awaited<ReturnType<typeof createProcessor>>
|
||||
async function createProcessor(input: {
|
||||
sessionID: string
|
||||
@@ -828,6 +893,10 @@ export namespace SessionPrompt {
|
||||
system: string[]
|
||||
agent: string
|
||||
abort: AbortSignal
|
||||
acpConnection?: {
|
||||
connection: any
|
||||
sessionId: string
|
||||
}
|
||||
}) {
|
||||
const toolcalls: Record<string, MessageV2.ToolPart> = {}
|
||||
let snapshot: string | undefined
|
||||
@@ -955,6 +1024,26 @@ export namespace SessionPrompt {
|
||||
},
|
||||
})
|
||||
toolcalls[value.id] = part as MessageV2.ToolPart
|
||||
|
||||
// Notify ACP client of pending tool call
|
||||
if (input.acpConnection) {
|
||||
await input.acpConnection.connection
|
||||
.sessionUpdate({
|
||||
sessionId: input.acpConnection.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: value.id,
|
||||
title: value.toolName,
|
||||
kind: determineToolKind(value.toolName),
|
||||
status: "pending",
|
||||
locations: [], // Will be populated when we have input
|
||||
rawInput: {},
|
||||
},
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
log.error("failed to send tool pending to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case "tool-input-delta":
|
||||
@@ -979,6 +1068,24 @@ export namespace SessionPrompt {
|
||||
metadata: value.providerMetadata,
|
||||
})
|
||||
toolcalls[value.toolCallId] = part as MessageV2.ToolPart
|
||||
|
||||
// Notify ACP client that tool is running
|
||||
if (input.acpConnection) {
|
||||
await input.acpConnection.connection
|
||||
.sessionUpdate({
|
||||
sessionId: input.acpConnection.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: value.toolCallId,
|
||||
status: "in_progress",
|
||||
locations: extractLocations(value.toolName, value.input),
|
||||
rawInput: value.input,
|
||||
},
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
log.error("failed to send tool in_progress to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -1000,6 +1107,33 @@ export namespace SessionPrompt {
|
||||
attachments: value.output.attachments,
|
||||
},
|
||||
})
|
||||
|
||||
// Notify ACP client that tool completed
|
||||
if (input.acpConnection) {
|
||||
await input.acpConnection.connection
|
||||
.sessionUpdate({
|
||||
sessionId: input.acpConnection.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: value.toolCallId,
|
||||
status: "completed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: value.output.output,
|
||||
},
|
||||
},
|
||||
],
|
||||
rawOutput: value.output,
|
||||
},
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
log.error("failed to send tool completed to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
|
||||
delete toolcalls[value.toolCallId]
|
||||
}
|
||||
break
|
||||
@@ -1021,6 +1155,35 @@ export namespace SessionPrompt {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Notify ACP client of tool error
|
||||
if (input.acpConnection) {
|
||||
await input.acpConnection.connection
|
||||
.sessionUpdate({
|
||||
sessionId: input.acpConnection.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: value.toolCallId,
|
||||
status: "failed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Error: ${(value.error as any).toString()}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
rawOutput: {
|
||||
error: (value.error as any).toString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
log.error("failed to send tool error to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
|
||||
if (value.error instanceof Permission.RejectedError) {
|
||||
blocked = true
|
||||
}
|
||||
@@ -1093,6 +1256,25 @@ export namespace SessionPrompt {
|
||||
currentText.text += value.text
|
||||
if (value.providerMetadata) currentText.metadata = value.providerMetadata
|
||||
if (currentText.text) await Session.updatePart(currentText)
|
||||
|
||||
// Send streaming chunk to ACP client
|
||||
if (input.acpConnection && value.text) {
|
||||
await input.acpConnection.connection
|
||||
.sessionUpdate({
|
||||
sessionId: input.acpConnection.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: value.text,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
log.error("failed to send text delta to ACP", { error: err })
|
||||
// Don't fail the whole request if ACP notification fails
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
|
||||
Reference in New Issue
Block a user