Challenge 3.5: Tool Approval
Wuerdest Du einem Agenten erlauben, ohne Rueckfrage Dateien zu loeschen, E-Mails zu versenden oder Daten in einer Datenbank zu ändern?
OVERVIEW
Abschnitt betitelt „OVERVIEW“Zwischen Tool Call und Tool Execution sitzt ein Approval Guard. Wenn ein Tool als kritisch markiert ist, fragt der Guard den User um Erlaubnis, bevor das Tool ausgefuehrt wird. Das ist Human-in-the-Loop — der Mensch behaelt die Kontrolle über gefaehrliche Aktionen.
Ohne Approval: Der Agent fuehrt alles aus, was das LLM entscheidet. Dateien loeschen, E-Mails versenden, Daten ueberschreiben — ohne Rueckfrage. Ein Fehler im Prompt oder eine Halluzination kann irreversiblen Schaden anrichten.
Mit Approval: Kritische Operationen werden erst nach menschlicher Freigabe ausgefuehrt. Du definierst, welche Tools sicher sind (automatisch ausführen) und welche gefaehrlich sind (erst fragen). Der Agent bleibt nuetzlich, aber kontrollierbar.
WALKTHROUGH
Abschnitt betitelt „WALKTHROUGH“Schicht 1: Statisches Approval mit needsApproval: true
Abschnitt betitelt „Schicht 1: Statisches Approval mit needsApproval: true“Die einfachste Form: Jeder Aufruf dieses Tools braucht eine Freigabe:
import { tool } from 'ai';import { z } from 'zod';
const deleteFileTool = tool({ description: 'Delete a file from the filesystem', inputSchema: z.object({ path: z.string().describe('The file path to delete'), }), needsApproval: true, // ← Immer Freigabe noetig execute: async ({ path }) => { // In Production: fs.unlink(path) return { deleted: path, status: 'success' }; },});Wenn needsApproval: true gesetzt ist, wird execute NICHT automatisch aufgerufen. Stattdessen muss der Aufrufer (Dein Code) die Freigabe erteilen, bevor die Ausfuehrung stattfindet.
Schicht 2: Dynamisches Approval
Abschnitt betitelt „Schicht 2: Dynamisches Approval“Manchmal soll die Approval-Logik vom Kontext abhaengen — z.B. nur bei bestimmten Pfaden oder ab einem bestimmten Betrag:
const deleteFileTool = tool({ description: 'Delete a file from the filesystem', inputSchema: z.object({ path: z.string().describe('The file path to delete'), }), needsApproval: async ({ path }) => { // ← Async Funktion // Nur Freigabe noetig für kritische Verzeichnisse return path.startsWith('/important/') || path.startsWith('/config/'); }, execute: async ({ path }) => { return { deleted: path, status: 'success' }; },});Die needsApproval-Funktion bekommt die gleichen Parameter wie execute. Sie gibt true zurück, wenn eine Freigabe noetig ist, und false, wenn das Tool direkt ausgefuehrt werden darf.
Schicht 3: Approval Flow mit generateText — das 2-Call Pattern
Abschnitt betitelt „Schicht 3: Approval Flow mit generateText — das 2-Call Pattern“Das AI SDK v6 hat keinen toolCallApproval Callback. Stattdessen nutzt es ein message-basiertes 2-Call Pattern: Du rufst generateText auf, pruefst das Ergebnis auf Approval Requests, fragst den User, und rufst generateText erneut mit der Antwort auf.
import { generateText, tool, stepCountIs, type ModelMessage, type ToolApprovalResponse,} from 'ai';import { anthropic } from '@ai-sdk/anthropic';import { z } from 'zod';import * as readline from 'readline';
const readFileTool = tool({ description: 'Read a file from the filesystem', inputSchema: z.object({ path: z.string().describe('The file path to read'), }), execute: async ({ path }) => ({ content: `Inhalt von ${path}: Lorem ipsum...`, }),});
const deleteFileTool = tool({ description: 'Delete a file from the filesystem', inputSchema: z.object({ path: z.string().describe('The file path to delete'), }), needsApproval: true, execute: async ({ path }) => ({ deleted: path, status: 'success', }),});
// Hilfsfunktion: User im Terminal fragenfunction askUser(question: string): Promise<boolean> { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => { rl.question(question, (answer) => { rl.close(); resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'ja'); }); });}
const tools = { readFile: readFileTool, deleteFile: deleteFileTool };const messages: ModelMessage[] = [ { role: 'user', content: 'Lies die Datei /tmp/test.txt und loesche sie danach.' },];
// Loop: generateText aufrufen, Approvals verarbeiten, erneut aufrufenlet result = await generateText({ model: anthropic('claude-sonnet-4-5-20250514'), tools, stopWhen: stepCountIs(5), messages,});
// Antwort-Messages in den Verlauf übernehmenmessages.push(...result.response.messages);
// Pruefen ob Approval Requests vorhanden sindwhile (result.content.some(part => part.type === 'tool-approval-request')) { const approvals: ToolApprovalResponse[] = [];
for (const part of result.content) { if (part.type === 'tool-approval-request') { const approved = await askUser( `Agent will ${part.toolCall.toolName}(${JSON.stringify(part.toolCall.input)}) ausführen. Erlauben? (y/n): ` ); approvals.push({ type: 'tool-approval-response', approvalId: part.approvalId, // ← Verknuepft Request und Response approved, reason: approved ? 'User hat genehmigt' : 'User hat abgelehnt', }); } }
// Approval-Antworten als Tool-Message anhaengen messages.push({ role: 'tool', content: approvals });
// Erneut aufrufen — bei Genehmigung wird das Tool jetzt ausgefuehrt result = await generateText({ model: anthropic('claude-sonnet-4-5-20250514'), tools, stopWhen: stepCountIs(5), messages, });
messages.push(...result.response.messages);}
console.log(result.text);So funktioniert das Pattern:
- Erster Aufruf:
generateTexterkennt, dassdeleteFileeine Freigabe braucht. Statt das Tool auszufuehren, kommt eintool-approval-requestinresult.contentzurück. - Approval Request prüfen: Jeder Request hat eine
approvalIdund dentoolCall(mittoolNameundinput). - User fragen: Du fragst den User und baust ein
ToolApprovalResponse-Objekt mit der gleichenapprovalId. - Zweiter Aufruf: Du haengst die Approvals als
{ role: 'tool', content: approvals }an die Messages und rufstgenerateTexterneut auf. - Loop: Bei Genehmigung wird das Tool ausgefuehrt. Bei Ablehnung reagiert das LLM darauf. Die
while-Schleife faengt auch Faelle ab, in denen der Agent nach der ersten Runde weitere Approval-pflichtige Tools aufrufen will.
Schicht 4: Das Dangerous/Safe Tool Pattern
Abschnitt betitelt „Schicht 4: Das Dangerous/Safe Tool Pattern“Ein bewaehrtes Pattern: Trenne Tools in sichere und gefaehrliche Kategorien:
import { tool } from 'ai';import { z } from 'zod';
// Sichere Tools — automatisch ausführenconst searchTool = tool({ description: 'Search for information (read-only)', inputSchema: z.object({ query: z.string().describe('Search query'), }), execute: async ({ query }) => ({ results: [`Ergebnis für "${query}"`], }),});
const readFileTool = tool({ description: 'Read a file (read-only)', inputSchema: z.object({ path: z.string().describe('File path'), }), execute: async ({ path }) => ({ content: `Inhalt von ${path}`, }),});
// Gefaehrliche Tools — immer Freigabeconst writeFileTool = tool({ description: 'Write content to a file (destructive)', inputSchema: z.object({ path: z.string().describe('File path'), content: z.string().describe('Content to write'), }), needsApproval: true, execute: async ({ path, content }) => ({ written: path, bytes: content.length, }),});
const deleteFileTool = tool({ description: 'Delete a file (destructive, irreversible)', inputSchema: z.object({ path: z.string().describe('File path'), }), needsApproval: true, execute: async ({ path }) => ({ deleted: path, }),});
// Alle Tools zusammen nutzen:const tools = { search: searchTool, // ← auto readFile: readFileTool, // ← auto writeFile: writeFileTool, // ← needs approval deleteFile: deleteFileTool, // ← needs approval};Faustregel: Read-only Operationen sind sicher. Alles was schreibt, loescht oder versendet braucht Approval. Im Zweifelsfall: Approval einbauen.
Datei: challenge-3-5.ts
Aufgabe: Baue ein File-Management-Tool-Set mit einem readFile (sicher) und einem deleteFile (mit Approval). Implementiere den message-basierten Approval Flow mit dem 2-Call Pattern.
import { generateText, tool, stepCountIs, type ModelMessage, type ToolApprovalResponse,} from 'ai';import { anthropic } from '@ai-sdk/anthropic';import { z } from 'zod';import * as readline from 'readline';
// TODO 1: Definiere readFileTool (kein Approval noetig)// - description: Datei lesen// - inputSchema: path (string)// - execute: Gibt simulierten Inhalt zurück
// TODO 2: Definiere deleteFileTool (mit needsApproval: true)// - description: Datei loeschen// - inputSchema: path (string)// - needsApproval: true// - execute: Gibt Loesch-Bestaetigung zurück
// TODO 3: Erstelle eine askUser() Hilfsfunktion// - Fragt im Terminal nach Freigabe// - Gibt true/false zurück
// TODO 4: Erstelle messages Array (ModelMessage[]) mit dem User-Prompt// - 'Lies /tmp/notes.txt und loesche die Datei danach.'
// TODO 5: Erster generateText-Aufruf mit tools, stopWhen, messages// - messages.push(...result.response.messages)
// TODO 6: While-Loop: result.content auf 'tool-approval-request' prüfen// - Für jeden Request: User fragen, ToolApprovalResponse bauen// - Approvals als { role: 'tool', content: approvals } an messages anhaengen// - generateText erneut aufrufen mit aktualisierten messages
// TODO 7: Logge result.text und alle StepsCheckliste:
-
readFileToolohne Approval definiert -
deleteFileToolmitneedsApproval: truedefiniert -
askUser()Hilfsfunktion implementiert - Erster
generateText-Aufruf mitmessagesArray -
result.contentauftool-approval-requestgeprueft -
ToolApprovalResponsemitapprovalIdgebaut - Zweiter
generateText-Aufruf mit Approval-Antworten in Messages - Bei “Nein”: Tool wird nicht ausgefuehrt, Agent reagiert darauf
Lösung anzeigen
import { generateText, tool, stepCountIs, type ModelMessage, type ToolApprovalResponse,} from 'ai';import { anthropic } from '@ai-sdk/anthropic';import { z } from 'zod';import * as readline from 'readline';
const readFileTool = tool({ description: 'Read a file from the filesystem (safe, read-only)', inputSchema: z.object({ path: z.string().describe('The file path to read'), }), execute: async ({ path }) => ({ path, content: `Inhalt von ${path}: Dies sind wichtige Notizen.`, size: 42, }),});
const deleteFileTool = tool({ description: 'Delete a file from the filesystem (destructive, irreversible)', inputSchema: z.object({ path: z.string().describe('The file path to delete'), }), needsApproval: true, execute: async ({ path }) => ({ deleted: path, status: 'success', }),});
function askUser(question: string): Promise<boolean> { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => { rl.question(question, (answer) => { rl.close(); resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'ja'); }); });}
const tools = { readFile: readFileTool, deleteFile: deleteFileTool };const messages: ModelMessage[] = [ { role: 'user', content: 'Lies die Datei /tmp/notes.txt und loesche sie danach.' },];
// Erster Aufruf — readFile wird direkt ausgefuehrt,// deleteFile erzeugt einen Approval Requestlet result = await generateText({ model: anthropic('claude-sonnet-4-5-20250514'), tools, stopWhen: stepCountIs(5), messages,});
messages.push(...result.response.messages);
// Loop: Solange Approval Requests kommen, User fragen und erneut aufrufenwhile (result.content.some(part => part.type === 'tool-approval-request')) { const approvals: ToolApprovalResponse[] = [];
for (const part of result.content) { if (part.type === 'tool-approval-request') { console.log(`\n--- Approval Request ---`); console.log(`Tool: ${part.toolCall.toolName}`); console.log(`Parameter: ${JSON.stringify(part.toolCall.input)}`);
const approved = await askUser('Ausfuehren? (y/n): '); console.log(approved ? 'Genehmigt.' : 'Abgelehnt.');
approvals.push({ type: 'tool-approval-response', approvalId: part.approvalId, approved, reason: approved ? 'User hat genehmigt' : 'User hat abgelehnt', }); } }
// Approval-Antworten an Messages anhaengen messages.push({ role: 'tool', content: approvals });
// Erneut aufrufen — Tool wird jetzt ausgefuehrt (oder abgelehnt) result = await generateText({ model: anthropic('claude-sonnet-4-5-20250514'), tools, stopWhen: stepCountIs(5), messages, });
messages.push(...result.response.messages);}
// Ergebnis ausgebenconsole.log('\n=== Ergebnis ===');console.log(`Schritte: ${result.steps.length}`);
const allToolCalls = result.steps.flatMap(s => s.toolCalls);console.log(`Tool Calls: ${allToolCalls.length}`);for (const call of allToolCalls) { console.log(` - ${call.toolName}(${JSON.stringify(call.args)})`);}console.log(`\nAntwort: ${result.text}`);Erklärung: Der Agent liest zuerst /tmp/notes.txt (automatisch, kein Approval noetig) und versucht dann die Datei zu loeschen. Beim deleteFile Call greift needsApproval: true: Statt das Tool auszufuehren, gibt generateText einen tool-approval-request in result.content zurück. Dein Code fragt den User im Terminal. Bei “y” wird eine genehmigende ToolApprovalResponse gebaut und als Tool-Message angehaengt. Der zweite generateText-Aufruf fuehrt das Tool dann aus. Bei “n” bekommt das LLM die Ablehnung und reagiert darauf — z.B. mit “Die Datei wurde nicht geloescht, da die Aktion abgelehnt wurde.”
Ausfuehren: npx tsx challenge-3-5.ts
Erwarteter Output (ungefaehr):
--- Approval Request ---Tool: deleteFileParameter: {"path":"/tmp/notes.txt"}Ausfuehren? (y/n): yGenehmigt.
=== Ergebnis ===Schritte: 1Tool Calls: 1 - deleteFile({"path":"/tmp/notes.txt"})
Antwort: Die Datei /tmp/notes.txt wurde gelesen und anschliessend geloescht.COMBINE
Abschnitt betitelt „COMBINE“Uebung: Erweitere den Research Agent aus Challenge 3.3 um einen Approval-Schritt für das Speichern von Ergebnissen.
- Nimm den Agent aus Challenge 3.3 mit
searchToolundsummarizeTool - Fuege ein
saveResultsToolhinzu mitneedsApproval: true - Prompt: “Recherchiere TypeScript, fasse zusammen und speichere die Ergebnisse in /tmp/research.txt.”
- Implementiere den 2-Call Loop: Erster
generateText-Aufruf, dannresult.contentauftool-approval-requestprüfen, User fragen,ToolApprovalResponsebauen, zweiter Aufruf - Logge ob der User die Speicherung genehmigt oder abgelehnt hat
Optional Stretch Goal: Implementiere dynamisches Approval: needsApproval als Funktion, die nur bei Pfaden ausserhalb von /tmp/ eine Freigabe verlangt. Teste mit verschiedenen Pfaden.