Tool Calls

エージェントの動作を、1ステップにつき1行で表示します。読み取りと検索は短い要約にまとめられ、編集、コマンド、承認、エラーは表示されたままになります。各ステップは、ファイル、差分、ターミナル、結果といった実際のビューに展開できます。AI SDKのすべてのツール状態に対応し、理由を添えて断る承認も含みます。

エージェントは、回答する前に何十もの小さなステップを踏むことがあります。すべてを表示すると回答が埋もれ、隠すとエージェントがブラックボックスのように感じられます。Tool Callsは、各ステップに、「Read components/search.tsx」や「Searched for useResults, 3 results」のように文章として読める控えめな1行を割り当てます。

連続して実行される読み取り、検索、参照は、「Explored 6 files」のような1つの要約にまとめられます。何かを変更するもの、判断が必要なもの、失敗したものは、実行中のライブタイマーとともに、それぞれ独立した行として残ります。任意のステップを開くと、ファイル、差分、ターミナル、結果といった実際の作業を確認できます。

AI SDKのすべてのツール状態には、承認を含め、それぞれ独自の見た目と文言があります。承認ではAllow、Deny、Always allow、そして入力した言葉を理由として送り返す「Tell it what to do instead」が提示され、キーボードでは⌘↵と⌘⌫が使えます。getToolPartStatusはツールパーツを自動で対応付け、検索結果、ファイル一覧、多肢選択式の質問、生のJSON用のビューが用意されています。

  1. Proレジストリをcomponents.jsonに追加する

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. トークンを追加する

    アカウントページでトークンを作成し、.env.local に HEXTAUI_PRO_TOKEN として設定してください。

  3. ブロックを追加する

    pnpm dlx shadcn@latest add @hextaui-pro/tool-calls

AI SDKと使う

getToolPartStatus でツールの part をステップに変換し、ツールごとに kind と view を選びます。チャットのストリーミングが止まったら stopped を渡すと、途中で打ち切られた呼び出しがいつまでもスピナーを回さず「キャンセル済み」と表示されます。

"use client"

import type * as React from "react"
import { useChat } from "@ai-sdk/react"
import {
  getToolName,
  isToolUIPart,
  lastAssistantMessageIsCompleteWithApprovalResponses,
  type DynamicToolUIPart,
  type ToolUIPart,
  type UIMessage,
} from "ai"

import { CodeBlock } from "../code-block/code-block"
import { CodeTerminal } from "../code-block/code-terminal"
import type { ToolCallProps } from "@/components/blocks/tool-calls/tool-call"
import { ToolCalls } from "@/components/blocks/tool-calls/tool-group"
import {
  getToolPartApproval,
  getToolPartStatus,
} from "@/components/blocks/tool-calls/tool-part"
import { ToolJson } from "@/components/blocks/tool-calls/tool-views"

type ToolPart = ToolUIPart | DynamicToolUIPart
type Fields = Record<string, string | number | undefined>

function describe(part: ToolPart): Partial<ToolCallProps> {
  const input = (part.input ?? {}) as Fields
  const output = (part.state === "output-available" ? part.output : {}) as Fields
  const done = part.state === "output-available"

  switch (getToolName(part)) {
    case "readFile":
      return {
        kind: "read",
        subject: String(input.path ?? ""),
        content: done ? (
          <CodeBlock
            code={String(output.content)}
            filename={String(input.path)}
            lineNumbers
          />
        ) : undefined,
      }
    case "editFile":
      return {
        kind: "edit",
        subject: String(input.path ?? ""),
        content: input.diff ? (
          <CodeBlock
            code={String(input.diff)}
            filename={String(input.path)}
            streaming={part.state === "input-streaming"}
            diff
          />
        ) : undefined,
      }
    case "runCommand":
      return {
        kind: "run",
        subject: String(input.command ?? ""),
        exitCode: done ? Number(output.exitCode) : undefined,
        content: (
          <CodeTerminal
            command={String(input.command ?? "")}
            output={String(output.stdout ?? "")}
            running={!done && part.state !== "approval-requested"}
            exitCode={done ? Number(output.exitCode) : undefined}
          />
        ),
      }
    default:
      return {
        kind: "other",
        name: getToolName(part),
        content: (
          <ToolJson
            input={part.input}
            output={done ? part.output : undefined}
            streaming={part.state === "input-streaming"}
          />
        ),
      }
  }
}

function AssistantMessage({
  message,
  streaming,
  onApproval,
}: {
  message: UIMessage
  streaming: boolean
  onApproval: (id: string, approved: boolean, reason?: string) => void
}) {
  const blocks: (React.ReactNode | ToolCallProps[])[] = []

  message.parts.forEach((part, index) => {
    if (isToolUIPart(part)) {
      const call: ToolCallProps = {
        id: part.toolCallId,
        title: part.title,
        status: getToolPartStatus(part, { stopped: !streaming }),
        approval: getToolPartApproval(part),
        error: part.state === "output-error" ? part.errorText : undefined,
        onApprove: () => part.approval && onApproval(part.approval.id, true),
        onDeny: (reason) =>
          part.approval && onApproval(part.approval.id, false, reason),
        ...describe(part),
      }
      const last = blocks.at(-1)
      if (Array.isArray(last)) last.push(call)
      else blocks.push([call])
    } else if (part.type === "text") {
      blocks.push(<p key={index}>{part.text}</p>)
    }
  })

  return (
    <div className="flex flex-col gap-4">
      {blocks.map((block, index) =>
        Array.isArray(block) ? (
          <ToolCalls key={block[0].id} calls={block} />
        ) : (
          <div key={index}>{block}</div>
        )
      )}
    </div>
  )
}

export function Chat() {
  const { messages, status, addToolApprovalResponse } = useChat({
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
  })
  const busy = status === "submitted" || status === "streaming"

  return messages.map((message, index) =>
    message.role === "assistant" ? (
      <AssistantMessage
        key={message.id}
        message={message}
        streaming={busy && index === messages.length - 1}
        onApproval={(id, approved, reason) =>
          addToolApprovalResponse({ id, approved, reason })
        }
      />
    ) : null
  )
}

コマンドを承認する

サーバー側でツールにneedsApprovalを設定し、addToolApprovalResponseで応答します。Always allowはプログラムを記憶し、拒否には代わりに行うことを理由として添えられます。ステップにフォーカスがある間は、⌘↵と⌘⌫が使えます。

"use client"

import * as React from "react"
import { useChat } from "@ai-sdk/react"
import {
  isToolUIPart,
  lastAssistantMessageIsCompleteWithApprovalResponses,
} from "ai"

import { CodeTerminal } from "../code-block/code-terminal"
import { ToolCall } from "@/components/blocks/tool-calls/tool-call"
import { getToolPartApproval, getToolPartStatus } from "@/components/blocks/tool-calls/tool-part"

function program(command: string) {
  return command.trim().split(/\s+/)[0] ?? ""
}

export function Chat() {
  const [allowed, setAllowed] = React.useState<string[]>([])
  const { messages, status, addToolApprovalResponse } = useChat({
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
  })
  const streaming = status === "submitted" || status === "streaming"

  React.useEffect(() => {
    for (const part of messages.at(-1)?.parts ?? []) {
      if (part.type !== "tool-runCommand") continue
      if (part.state !== "approval-requested") continue
      const command = String((part.input as { command?: string }).command)
      if (allowed.includes(program(command))) {
        addToolApprovalResponse({ id: part.approval.id, approved: true })
      }
    }
  }, [messages, allowed, addToolApprovalResponse])

  return messages.flatMap((message) =>
    message.parts.map((part) => {
      if (!isToolUIPart(part) || part.type !== "tool-runCommand") return null
      const command = String((part.input as { command?: string })?.command ?? "")
      const output = part.state === "output-available"
        ? (part.output as { stdout: string; exitCode: number })
        : undefined

      return (
        <ToolCall
          key={part.toolCallId}
          id={part.toolCallId}
          kind="run"
          subject={command}
          status={getToolPartStatus(part, { stopped: !streaming })}
          exitCode={output?.exitCode}
          approval={{
            ...getToolPartApproval(part),
            alwaysLabel: `Always allow ${program(command)}`,
          }}
          onApprove={({ always }) => {
            if (!part.approval) return
            if (always) setAllowed((list) => [...list, program(command)])
            addToolApprovalResponse({ id: part.approval.id, approved: true })
          }}
          onDeny={(reason) => {
            if (!part.approval) return
            addToolApprovalResponse({
              id: part.approval.id,
              approved: false,
              reason,
            })
          }}
          content={
            <CodeTerminal
              command={command}
              output={output?.stdout}
              exitCode={output?.exitCode}
              running={part.state === "approval-responded"}
            />
          }
        />
      )
    })
  )
}

ユーザーに尋ねる

execute関数を持たないクライアントツールは、回答を待ちます。AskUserで選択肢を表示し、選ばれたものをaddToolOutputで返します。

"use client"

import { useChat } from "@ai-sdk/react"
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai"

import { ToolCall } from "@/components/blocks/tool-calls/tool-call"
import { getToolPartStatus } from "@/components/blocks/tool-calls/tool-part"
import { AskUser, type AskOption } from "@/components/blocks/tool-calls/tool-views"

export function Chat() {
  const { messages, addToolOutput } = useChat({
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
  })

  return messages.flatMap((message) =>
    message.parts.map((part) => {
      if (part.type !== "tool-askUser") return null
      const input = (part.input ?? {}) as {
        question?: string
        options?: AskOption[]
      }

      return (
        <ToolCall
          key={part.toolCallId}
          id={part.toolCallId}
          kind="ask"
          subject={input.question}
          status={getToolPartStatus(part, { waiting: true })}
          content={
            <AskUser
              question={input.question ?? ""}
              options={input.options ?? []}
              answer={
                part.state === "output-available"
                  ? String(part.output)
                  : undefined
              }
              onAnswer={(value) =>
                addToolOutput({
                  tool: "askUser",
                  toolCallId: part.toolCallId,
                  output: value,
                })
              }
            />
          }
        />
      )
    })
  )
}

構造

外側から内側へ組み合わせるパーツ。

パーツ説明
ToolCallsリスト。目立たない実行をグループ化し、ステップ間で所要時間を共有します。
ToolCall1つのステップ。アイコン、文、メタ情報、タイマー、開いたときの内容。
ToolGroup要約行を持つ、目立たないステップの折りたたまれた連続。
ToolApproval承認待ちのステップ内に表示される、承認・拒否・リダイレクトのカード。
SearchResults, FileList, AskUser, ToolJsonステップの content として渡す view。

ToolCalls

childrenを除くすべてのolプロップも受け付けます。

プロパティ型デフォルト
calls実行された順のステップ。
ToolCallProps[]–
group目立たないステップの連続を折りたたみます。すべてのステップを表示するにはfalseにします。
booleantrue
プロパティ型デフォルト
id安定したid。通常はtoolCallIdです。時間はidごとに保持されます。
string–
statusstreaming、running、waiting、approval、done、error、denied、cancelled のいずれか。
ToolStatus–
kindread、search、list、edit、write、run、web、fetch、ask、other のいずれか。アイコンと動詞が決まります。
ToolKind"other"
subject対象となったもの。パス、クエリ、URL、またはコマンド。
string–
nameツール名。kind が other のときの文に使われます。
string–
title生成された文を完全に置き換えます。
string–
meta文の後に付く短い結果。「3 results」や「+12 −3」など。
ReactNode–
contentステップを開いたときに表示されます。
ReactNode–
exitCode実行ステップ用。0以外の場合、ステップは失敗としてマークされ、開かれます。
number–
errorエラー状態で表示されるメッセージ。
string–
duration履歴用に保存される所要時間(秒)。
number–
approval承認のための理由、結果、Always allowのラベル。
ToolCallApproval–
defaultOpenステップを開いた状態で始めるかどうかを上書きします。
boolean–
onApproveAllowまたはAlways allowから呼ばれます。
(options: { always: boolean }) => void–
onDenyDeny、または「Tell it what to do instead」のテキストとともに呼ばれます。
(reason?: string) => void–
プロパティ型デフォルト
reasonボタンの上に表示される、承認が必要な理由。
string–
approved回答済みの場合の回答。
boolean–
automaticルールによって承認されたため、カードは表示されません。
boolean–
denialReason拒否されたステップに表示される、代わりにユーザーが求めた内容。
string–
alwaysLabelこのラベルで「Always allow …」を表示します。たとえばプログラム名です。
string–

getToolPartStatus(part, options)

AI SDKのツールパーツをToolStatusに対応付けます。

プロパティ型デフォルト
partmessage.parts の part。
ToolUIPart | DynamicToolUIPart–
options.stoppedチャットが停止したため、未完了の呼び出しはスピナーではなく「キャンセル済み」と表示されます。
booleanfalse
options.waitingクライアントツールが、AskUserなど、ユーザーの応答を待っています。
booleanfalse
プロパティ型デフォルト
question質問。
string–
options選択肢。
{ value, label, description? }[]–
answer回答済みの場合の選択された値。
string–
onAnsweraddToolOutputで送り返します。
(value: string) => void–
プロパティ型デフォルト
query各一致箇所の内側でハイライトされます。
string–
matches一致した項目。
{ path, line, text }[]–
limit「Show all」の前に表示されます。
number6
キーアクション
EnterSpaceステップまたは折りたたまれたグループを開閉します。
⌘↵フォーカスがステップ内にあるとき、承認待ちのステップを許可します。
⌘⌫フォーカスがステップ内にあるとき、承認待ちのステップを拒否します。
Esc送信せずに「Tell it what to do instead」を閉じます。
  • ステップは順序付きリストなので、スクリーンリーダーはステップの総数と現在の位置を読み上げます。
  • ユーザーの対応が必要なステップは、質問やエラーとともに、「承認が必要: Run pnpm test」のように polite なライブリージョンで通知されます。通常のステップは通知されないため、スクリーンリーダーが読み上げで溢れることはありません。
  • ステータスは色だけで示されることはありません。各状態には、独自のアイコンと文言があります。
  • 承認のショートカットは、フィールドへの入力中は無視され、リダイレクトのフィールドは開くと自動でフォーカスし、閉じるとフォーカスを戻します。

使用技術

Tool Calls を構成する無料のHextaUIコンポーネントです。それぞれ単独でインストールできます。

コード

8 個のファイルを components/blocks/tool-calls に追加しました。