opencode/packages/cli/src/acp/tool.ts
2026-07-21 21:13:21 +05:30

222 lines
7.4 KiB
TypeScript

import { isAbsolute, resolve } from "node:path"
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
export type ToolInput = Record<string, unknown>
export type ToolContent = ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
>
export function toToolKind(toolName: string): ToolKind {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell":
return "execute"
case "webfetch":
return "fetch"
case "edit":
case "apply_patch":
case "patch":
case "write":
return "edit"
case "grep":
case "glob":
case "context":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return "search"
case "read":
return "read"
case "task":
case "subagent":
return "think"
default:
return "other"
}
}
export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] {
switch (toolName.toLocaleLowerCase()) {
case "bash":
case "shell": {
const workdir = shellWorkdir(input, cwd)
return workdir ? [{ path: workdir }] : []
}
case "read":
case "edit":
case "write":
case "patch":
case "apply_patch":
return locationFrom(input.filePath ?? input.filepath)
case "external_directory":
return locationFrom(input.filePath ?? input.filepath, input.parentDir, input.directories)
case "grep":
case "glob":
case "context":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return locationFrom(input.path)
default:
return []
}
}
export function pendingToolCall(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
readonly cwd?: string
}): ToolCall {
return {
toolCallId: input.toolCallId,
title: toolTitle(input.toolName, input.state.input, input.state.title),
kind: toToolKind(input.toolName),
status: "pending",
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
}
}
export function runningToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
readonly content?: ToolContent
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "in_progress",
kind: toToolKind(input.toolName),
title: toolTitle(input.toolName, input.state.input, input.state.title),
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
...(input.content?.length ? { content: toolContent(input.content) } : {}),
}
}
export function completedToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly structured: Readonly<Record<string, unknown>>
readonly result?: unknown
}): ToolCallUpdate {
const normalized = toolContent(input.content)
const read = input.toolName.toLocaleLowerCase() === "read" ? readDisplayText(input.structured) : undefined
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
const primary =
read === undefined
? normalized.filter((part) => !images.includes(part))
: [{ type: "content" as const, content: { type: "text" as const, text: read } }]
const oldText = stringValue(input.input.oldString)
const newText = stringValue(input.input.newString)
const diff: ToolCallContent[] =
oldText === undefined || newText === undefined
? []
: [
{
type: "diff",
path: stringValue(input.input.path) ?? stringValue(input.input.filePath) ?? "",
oldText,
newText,
},
]
return {
toolCallId: input.toolCallId,
status: "completed",
content: [...primary, ...diff, ...images],
rawOutput: {
structured: input.structured,
...(input.result === undefined ? {} : { result: input.result }),
},
}
}
export function errorToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly input: ToolInput
readonly content: ToolContent
readonly structured: Readonly<Record<string, unknown>>
readonly error: string
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "failed",
kind: toToolKind(input.toolName),
title: toolTitle(input.toolName, input.input, undefined),
locations: toLocations(input.toolName, input.input, input.cwd),
rawInput: rawInput(input.toolName, input.input, input.cwd),
content: [...toolContent(input.content), { type: "content", content: { type: "text", text: input.error } }],
rawOutput: { structured: input.structured, error: input.error },
}
}
function toolContent(content: ToolContent): ToolCallContent[] {
return content.flatMap((part): ToolCallContent[] => {
if (part.type === "text") return [{ type: "content", content: { type: "text", text: part.text } }]
const match = /^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/.exec(part.uri)
if (!match?.[1]?.startsWith("image/") || match[2] === undefined) return []
return [{ type: "content", content: { type: "image", mimeType: match[1], data: match[2] } }]
})
}
function readDisplayText(structured: Readonly<Record<string, unknown>>) {
if (typeof structured.content === "string") {
if (structured.type === "text-page" || structured.encoding === "utf8") return structured.content
}
if (!Array.isArray(structured.entries)) return undefined
return structured.entries
.flatMap((entry): string[] => {
if (typeof entry === "string") return [entry]
if (!entry || typeof entry !== "object") return []
const path = Reflect.get(entry, "path")
return typeof path === "string" ? [path] : []
})
.join("\n")
}
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
if (isShell(toolName)) return stringValue(input.command) ?? stringValue(input.cmd) ?? fallback ?? toolName
return fallback || toolName
}
function rawInput(toolName: string, input: ToolInput, cwd?: string): ToolInput {
if (!isShell(toolName) || input.cwd || input.workdir) return input
const workdir = shellWorkdir(input, cwd)
return workdir ? { ...input, cwd: workdir } : input
}
function shellWorkdir(input: ToolInput, cwd?: string) {
const explicit = stringValue(input.workdir) ?? stringValue(input.cwd)
if (!explicit) return cwd
return isAbsolute(explicit) ? explicit : resolve(cwd ?? process.cwd(), explicit)
}
function isShell(toolName: string) {
const tool = toolName.toLocaleLowerCase()
return tool === "bash" || tool === "shell"
}
function locationFrom(...values: unknown[]): ToolCallLocation[] {
return Array.from(
new Set(
values.flatMap((value): string[] => {
if (Array.isArray(value))
return value.filter((item): item is string => typeof item === "string" && item.length > 0)
const path = stringValue(value)
return path ? [path] : []
}),
),
(path) => ({ path }),
)
}
export function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
export * as ACPTool from "./tool"