365 lines
10 KiB
TypeScript
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
|
|
}
|