Tool Calls

Zeige, was ein Agent tut, eine Zeile pro Schritt. Lesen und Suchen werden zu einer kurzen Zusammenfassung gefaltet, während Änderungen, Befehle, Freigaben und Fehler sichtbar bleiben. Jeder Schritt öffnet sich zu einer echten Ansicht: der Datei, dem Diff, dem Terminal oder den Ergebnissen. Jeder Tool-Zustand des AI SDK ist abgedeckt, einschließlich Freigaben mit der Möglichkeit, stattdessen eine Begründung anzugeben.

Agenten können Dutzende kleiner Schritte machen, bevor sie antworten. Jeden zu zeigen, begräbt die Antwort, und sie zu verbergen, lässt den Agenten wie eine Blackbox wirken. Tool Calls gibt jedem Schritt eine ruhige Zeile, die sich wie ein Satz liest, etwa „Read components/search.tsx“ oder „Searched for useResults, 3 results“.

Lesen, Suchen und Nachschlagen, die direkt hintereinander laufen, falten sich zu einer einzigen Zusammenfassung wie „Explored 6 files“. Alles, was etwas ändert, eine Entscheidung braucht oder fehlschlägt, bleibt in einer eigenen Zeile, mit einem Live-Timer, solange es läuft. Öffne einen beliebigen Schritt, um die echte Arbeit zu sehen: die Datei, den Diff, das Terminal oder die Ergebnisse.

Jeder Tool-Zustand des AI SDK hat sein eigenes Aussehen und seine eigene Formulierung, auch Freigaben. Die Freigabe bietet Allow, Deny, Always allow und „Tell it what to do instead“, das deine Worte als Grund zurückschickt, mit ⌘↵ und ⌘⌫ per Tastatur. getToolPartStatus ordnet Tool-Parts für dich zu, und fertige Ansichten decken Suchergebnisse, Dateilisten, Multiple-Choice-Fragen und rohes JSON ab.

  1. Die Pro-Registry zu components.json hinzufügen

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. Token hinzufügen

    Erstelle auf deiner Kontoseite einen Token und trage ihn in .env.local als HEXTAUI_PRO_TOKEN ein.

  3. Den Block hinzufügen

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

Mit dem AI SDK

Wandle Tool-Parts mit getToolPartStatus in Schritte um und wähle pro Tool eine kind und eine View. Übergib stopped, sobald der Chat nicht mehr streamt, damit ein mittendrin abgebrochener Aufruf „Cancelled“ anzeigt, statt endlos zu drehen.

"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
  )
}

Befehle freigeben

Markiere ein Tool auf dem Server mit needsApproval und antworte mit addToolApprovalResponse. Always allow merkt sich das Programm, und eine Ablehnung kann als Grund mitgeben, was stattdessen zu tun ist. ⌘↵ und ⌘⌫ funktionieren, solange der Schritt den Fokus hat.

"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"}
            />
          }
        />
      )
    })
  )
}

Den Nutzer fragen

Ein Client-Tool ohne execute-Funktion wartet auf eine Antwort. Zeige die Auswahl mit AskUser und sende die Wahl mit addToolOutput zurück.

"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,
                })
              }
            />
          }
        />
      )
    })
  )
}

Aufbau

Die Teile, die du zusammensetzt, von außen nach innen.

PartBeschreibung
ToolCallsDie Liste. Gruppiert ruhige Durchläufe und teilt die Zeitmessung zwischen den Schritten.
ToolCallEin Schritt: Icon, Satz, Meta, Timer und sein Inhalt, wenn geöffnet.
ToolGroupEin eingeklappter Lauf ruhiger Schritte mit einer Zusammenfassungszeile.
ToolApprovalDie Karte zum Genehmigen, Ablehnen und Umleiten, die in einem Schritt angezeigt wird, der auf Freigabe wartet.
SearchResults, FileList, AskUser, ToolJsonViews, die als Inhalt eines Schritts übergeben werden.

ToolCalls

Akzeptiert auch alle ol-Props außer children.

PropTypStandard
callsDie Schritte in der Reihenfolge, in der sie ausgeführt wurden.
ToolCallProps[]–
groupFaltet Läufe ruhiger Schritte. Setze false, um jeden Schritt zu zeigen.
booleantrue
PropTypStandard
idStabile ID, meist die toolCallId. Das Timing wird pro ID geführt.
string–
statusstreaming, running, waiting, approval, done, error, denied oder cancelled.
ToolStatus–
kindread, search, list, edit, write, run, web, fetch, ask oder other. Bestimmt Icon und Verb.
ToolKind"other"
subjectWorauf sich der Schritt bezog: ein Pfad, eine Suchanfrage, eine URL oder ein Befehl.
string–
nameTool-Name, der für den Satz verwendet wird, wenn kind other ist.
string–
titleErsetzt den generierten Satz vollständig.
string–
metaKurzes Ergebnis nach dem Satz, etwa „3 results“ oder „+12 −3“.
ReactNode–
contentWird angezeigt, wenn der Schritt geöffnet ist.
ReactNode–
exitCodeFür Run-Schritte. Ein Wert ungleich null markiert den Schritt als fehlgeschlagen und öffnet ihn.
number–
errorMeldung, die für den Fehlerstatus angezeigt wird.
string–
durationGespeicherte Dauer in Sekunden, für den Verlauf.
number–
approvalGrund, Ergebnis und Always-allow-Label für Freigaben.
ToolCallApproval–
defaultOpenÜberschreibt, ob der Schritt geöffnet startet.
boolean–
onApproveWird von Allow oder Always allow aufgerufen.
(options: { always: boolean }) => void–
onDenyWird von Deny oder mit Text aus „Tell it what to do instead“ aufgerufen.
(reason?: string) => void–
PropTypStandard
reasonWarum eine Freigabe nötig ist, angezeigt über den Buttons.
string–
approvedDie Antwort, sobald sie gegeben wurde.
boolean–
automaticDurch eine Regel freigegeben, daher wird keine Card angezeigt.
boolean–
denialReasonWas die Person stattdessen verlangt hat, angezeigt bei abgelehnten Schritten.
string–
alwaysLabelZeigt „Always allow …“ mit diesem Label, zum Beispiel dem Programmnamen.
string–

getToolPartStatus(part, options)

Ordnet einen Tool-Part des AI SDK einem ToolStatus zu.

PropTypStandard
partDer Part aus message.parts.
ToolUIPart | DynamicToolUIPart–
options.stoppedDer Chat wurde gestoppt, daher zeigen unfertige Aufrufe „Cancelled“ an, statt weiter zu drehen.
booleanfalse
options.waitingEin Client-Tool wartet auf die Person, zum Beispiel AskUser.
booleanfalse
PropTypStandard
questionDie Frage.
string–
optionsDie Auswahlmöglichkeiten.
{ value, label, description? }[]–
answerDer gewählte Wert, sobald geantwortet wurde.
string–
onAnswerSchicke es mit addToolOutput zurück.
(value: string) => void–
PropTypStandard
queryInnerhalb jedes Treffers hervorgehoben.
string–
matchesDie Treffer.
{ path, line, text }[]–
limitWird vor „Show all“ angezeigt.
number6
TasteAktion
EnterSpaceÖffnet oder schließt einen Schritt oder eine gefaltete Gruppe.
⌘↵Erlaubt einen Schritt, der auf Freigabe wartet, solange der Fokus in ihm liegt.
⌘⌫Verweigert einen Schritt, der auf Freigabe wartet, solange der Fokus in ihm liegt.
EscVerlässt „Tell it what to do instead“, ohne zu senden.
  • Schritte sind eine geordnete Liste, sodass Screenreader ansagen, wie viele Schritte es gibt und an welcher Stelle sich jeder befindet.
  • Schritte, die eine Person brauchen, werden über eine Live-Region mit polite-Priorität angesagt, etwa „Freigabe erforderlich: Run pnpm test“, ebenso Fragen und Fehler. Routineschritte bleiben still, damit Screenreader nicht überflutet werden.
  • Der Status ist nie nur Farbe: Jeder Zustand hat sein eigenes Icon und seine eigene Formulierung.
  • Freigabe-Kürzel werden ignoriert, solange in einem Feld getippt wird, und das Umleitungsfeld fokussiert sich beim Öffnen selbst und gibt den Fokus beim Schließen zurück.

Gebaut mit

Die kostenlosen HextaUI-Komponenten, aus denen Tool Calls besteht. Jede lässt sich einzeln installieren.

Code

8 Dateien, hinzugefügt zu components/blocks/tool-calls.