Files
anomalyco_opencode/packages/opencode/src/acp/tool.ts
T
2026-06-26 22:21:35 +05:30

365 lines
10 KiB
TypeScript

import { isAbsolute, resolve } from "path"
import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk"
export type ToolInput = Record<string, unknown>
export type ToolAttachment = {
readonly mime?: string
readonly url?: string
readonly [key: string]: unknown
}
export type CompletedToolState = {
readonly status: "completed"
readonly input: ToolInput
readonly output: string
readonly metadata?: unknown
readonly attachments?: ReadonlyArray<ToolAttachment>
}
export type RunningToolState = {
readonly status: "running"
readonly input: ToolInput
readonly title?: string
}
export type ErrorToolState = {
readonly status: "error"
readonly input: ToolInput
readonly error: string
readonly metadata?: unknown
}
export type ImageAttachment = {
readonly mimeType: string
readonly data: string
}
export function toToolKind(toolName: string): ToolKind {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
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":
return "think"
default:
return "other"
}
}
export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
case "bash":
case "shell": {
const workdir = shellWorkdir(input, cwd)
return workdir ? [{ path: workdir }] : []
}
case "read":
case "edit":
case "write":
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 completedToolContent(toolName: string, state: CompletedToolState): ToolCallContent[] {
const text =
toolName.toLocaleLowerCase() === "read" ? (readDisplayText(state.metadata) ?? state.output) : state.output
const content: ToolCallContent[] = [
{
type: "content",
content: {
type: "text",
text,
},
},
]
if (toToolKind(toolName) === "edit") {
content.push(...diffContent(state.input))
}
content.push(...imageContents(state.attachments ?? []))
return content
}
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: RunningToolState
readonly output?: string
readonly cwd?: string
}): ToolCallUpdate {
const content = input.output
? [
{
type: "content" as const,
content: {
type: "text" as const,
text: input.output,
},
},
]
: undefined
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),
...(content ? { content } : {}),
}
}
export function duplicateRunningToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: RunningToolState
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),
}
}
export function completedToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: CompletedToolState & { readonly title?: string }
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "completed",
...(input.state.title ? { title: input.state.title } : {}),
content: completedToolContent(input.toolName, input.state),
rawOutput: completedToolRawOutput(input.state),
}
}
export function errorToolUpdate(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: ErrorToolState
readonly cwd?: string
}): ToolCallUpdate {
return {
toolCallId: input.toolCallId,
status: "failed",
kind: toToolKind(input.toolName),
title: toolTitle(input.toolName, input.state.input, undefined),
locations: toLocations(input.toolName, input.state.input, input.cwd),
rawInput: rawInput(input.toolName, input.state.input, input.cwd),
content: [
{
type: "content",
content: {
type: "text",
text: input.state.error,
},
},
],
rawOutput: {
error: input.state.error,
metadata: input.state.metadata,
},
}
}
export function completedToolRawOutput(state: CompletedToolState) {
return {
output: state.output,
...(state.metadata !== undefined ? { metadata: state.metadata } : {}),
...(state.attachments?.length ? { attachments: state.attachments } : {}),
}
}
export function imageContents(attachments: ReadonlyArray<ToolAttachment>): ToolCallContent[] {
return extractImageAttachments(attachments).map((attachment): ToolCallContent => {
return {
type: "content",
content: {
type: "image",
mimeType: attachment.mimeType,
data: attachment.data,
},
}
})
}
export function extractImageAttachments(attachments: ReadonlyArray<ToolAttachment>): ImageAttachment[] {
return attachments.flatMap((attachment): ImageAttachment[] => {
const data = dataUrlImage(attachment)
return data ? [data] : []
})
}
export function shellOutputSnapshot(state: { readonly metadata?: unknown }) {
if (!state.metadata || typeof state.metadata !== "object") return undefined
return stringValue((state.metadata as Record<string, unknown>).output)
}
// For shell tools, surface the actual command as the title so it stays visible
// before output lands; non-shell tools keep their model-provided title.
function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) {
if (isShell(toolName)) return shellCommand(input) ?? fallback ?? toolName
return fallback || toolName
}
// Enrich shell rawInput with the resolved working directory so clients can show
// where the command runs, unless the model already specified one.
function rawInput(toolName: string, input: ToolInput, cwd?: string): ToolInput {
if (!isShell(toolName)) return input
if (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)
return resolvePath(explicit, cwd) ?? cwd
}
function resolvePath(value: string | undefined, cwd?: string) {
if (!value) return undefined
if (isAbsolute(value)) return value
return resolve(cwd ?? process.cwd(), value)
}
function shellCommand(input: ToolInput) {
return stringValue(input.command) ?? stringValue(input.cmd)
}
function isShell(toolName: string) {
const tool = toolName.toLocaleLowerCase()
return tool === "bash" || tool === "shell"
}
export const mapToolKind = toToolKind
export const extractLocations = toLocations
export const buildCompletedToolContent = completedToolContent
export const buildCompletedRawOutput = completedToolRawOutput
export const extractShellOutputSnapshot = shellOutputSnapshot
export const buildPendingToolCall = pendingToolCall
export const buildRunningToolUpdate = runningToolUpdate
export const buildDuplicateRunningToolUpdate = duplicateRunningToolUpdate
export const buildCompletedToolUpdate = completedToolUpdate
export const buildErrorToolUpdate = errorToolUpdate
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 }),
)
}
function diffContent(input: ToolInput): ToolCallContent[] {
const oldText = stringValue(input.oldString)
const newText = stringValue(input.newString) ?? stringValue(input.content)
if (oldText === undefined || newText === undefined) return []
return [
{
type: "diff",
path: stringValue(input.filePath) ?? "",
oldText,
newText,
},
]
}
function readDisplayText(metadata: unknown) {
if (!metadata || typeof metadata !== "object") return undefined
const display = (metadata as Record<string, unknown>).display
if (!display || typeof display !== "object") return undefined
const info = display as Record<string, unknown>
if (info.type === "file") return stringValue(info.text)
if (info.type === "directory" && Array.isArray(info.entries)) {
return info.entries.filter((item): item is string => typeof item === "string").join("\n")
}
return undefined
}
function dataUrlImage(attachment: ToolAttachment) {
const match = stringValue(attachment.url)?.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/)
const mime = match?.[1] ?? stringValue(attachment.mime)
if (!mime?.startsWith("image/")) return undefined
const data = match?.[2]
if (data === undefined) return undefined
return { mimeType: mime, data }
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}