786 lines
29 KiB
Markdown
786 lines
29 KiB
Markdown
# Les 14 — Agents
|
||
## Docenttekst (Klas A — 3 uur, fysiek, demo-driven)
|
||
|
||
**Les:** 13 van 18
|
||
**Onderwerp:** Agents — LLM in een loop met tools
|
||
**Duur:** 180 minuten
|
||
**Format:** Tim demonstreert klassikaal. Studenten kijken mee. Zelf bouwen = huiswerk.
|
||
**Demo-app:** Nieuwe research-agent (from scratch, los van Polderfest)
|
||
|
||
---
|
||
|
||
## Hoe deze tekst werkt
|
||
|
||
Dit document is een **lopend script**. Lees mee tijdens de les op je laptop.
|
||
|
||
- `[SLIDE X]` — Klik naar slide X
|
||
- `[SCHERM: slides | terminal | editor | browser | supabase]` — Welk scherm op de beamer
|
||
- **Vertel:** "..." — Letterlijk wat je zegt (mag in eigen woorden)
|
||
- `*[stage direction]*` — Korte instructie voor jezelf, niet uitspreken
|
||
- Code blocks = wat je typt
|
||
- 💬 = verwachte studentenvraag
|
||
|
||
---
|
||
|
||
## VÓÓR DE LES — Setup (45 min)
|
||
|
||
### 1. Tavily account
|
||
|
||
- Maak account op https://tavily.com/
|
||
- Kopieer API key (begint met `tvly-...`)
|
||
- Gratis tier = 1000 calls/maand, ruim genoeg voor deze les
|
||
|
||
### 2. Nieuwe lege Next.js demo-folder
|
||
|
||
`*[Doe dit thuis vooraf, niet in de klas. Klas kijkt naar de setup-stap als je 'm doet.]*`
|
||
|
||
```bash
|
||
cd ~/novi/novi-lessons/Les14-Agents
|
||
pnpm create next-app@latest research-agent \
|
||
--typescript --tailwind --app --no-src-dir --import-alias "@/*"
|
||
cd research-agent
|
||
pnpm add ai @ai-sdk/openai zod @supabase/supabase-js
|
||
```
|
||
|
||
### 3. Environment vars
|
||
|
||
`.env.local`:
|
||
```
|
||
OPENAI_API_KEY=sk-...
|
||
TAVILY_API_KEY=tvly-...
|
||
NEXT_PUBLIC_SUPABASE_URL=...
|
||
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
|
||
```
|
||
|
||
### 4. Supabase tabel
|
||
|
||
```sql
|
||
create table research_reports (
|
||
id bigserial primary key,
|
||
query text not null,
|
||
summary text not null,
|
||
sources jsonb default '[]'::jsonb,
|
||
created_at timestamp default now()
|
||
);
|
||
alter table research_reports enable row level security;
|
||
create policy "demo open" on research_reports
|
||
for all to anon using (true) with check (true);
|
||
```
|
||
|
||
### 5. Backup eindstaat
|
||
|
||
- Werkende `lib/tools.ts`, `lib/agent.ts`, `app/api/research/route.ts` ergens als backup
|
||
- Verwacht: 1-2 typos tijdens live coding — geen ramp
|
||
|
||
### 6. Browser tabs
|
||
|
||
- `localhost:3000` (dev server, maar gaan we voornamelijk via terminal testen)
|
||
- Supabase dashboard
|
||
- https://ai-sdk.dev/docs/agents/overview (referentie)
|
||
- https://tavily.com/ (dashboard voor usage)
|
||
|
||
---
|
||
|
||
# HET SCRIPT — Lees mee tijdens de les
|
||
|
||
## BLOK 1 — Welkom + Terugblik (10 min)
|
||
|
||
`[SLIDE 1 — Title]` `[SCHERM: slides]`
|
||
|
||
**Vertel:** "Welkom bij les 13. Vandaag stappen we van tool-calling naar agents. Eén stap verder qua autonomie van AI."
|
||
|
||
`[SLIDE 2 — Terugblik]`
|
||
|
||
**Vertel:** "Vorige les bouwden we Tool Calling in onze Polderfest-app. Zes tools. `stopWhen: stepCountIs(5)`. AI roept zelf functies aan om aan data te komen. Goed werk.
|
||
|
||
Maar er waren dingen die een tool-call bot **nog niet kon**. Lange ketens — 30, 40, 50 stappen — daar is `stepCountIs(5)` te krap voor. Een eigen plan maken en bijstellen. Tools die andere tools triggeren. Per stap een ander model kiezen.
|
||
|
||
Daarvoor heb je een **agent** nodig. Een LLM die tools gebruikt in een loop, en zelf bepaalt hoe lang die loop duurt en welke kant het op gaat."
|
||
|
||
`[SLIDE 3 — Planning]`
|
||
|
||
**Vertel:** "Vandaag drie uur. Eerst een half uur theorie — wat is een agent precies, hoe ziet de loop eruit, wat is een `ToolLoopAgent`. Daarna vier live demos waarin we from scratch een research-agent bouwen. Geen polderfest vandaag — losse repo, omdat agents een nieuwe context verdienen.
|
||
|
||
Aan het eind: wanneer wel/niet een agent, en wat doe je voor huiswerk."
|
||
|
||
---
|
||
|
||
## BLOK 2 — Theorie (30 min)
|
||
|
||
`[SLIDE 4 — Wat is een agent]`
|
||
|
||
**Vertel:** "De definitie uit de AI SDK docs is kort en bruikbaar:
|
||
|
||
> *Agents are LLMs that use tools in a loop to accomplish tasks.*
|
||
|
||
Drie ingrediënten. Eén — een LLM die besluit wat de volgende actie is. Twee — tools die het LLM uitbreiden. Drie — een loop die deze twee orchestreert.
|
||
|
||
In Les 12 hadden we deze drie ook al — `stopWhen: stepCountIs(5)` is in feite een mini-agent. Het verschil zit in **schaal**. Vandaag praten we over 20-50 stappen, en het model bepaalt zelf welke tools het wanneer gebruikt."
|
||
|
||
💬 *Mogelijke vraag: "Wat is dan het verschil met Les 12 echt?"*
|
||
|
||
**Antwoord:** "Schaal en autonomie. Les 12 was: vraag → 2-5 tool-calls → antwoord. Vandaag: vraag → agent maakt plan → voert plan uit (10-50 stappen) → schrijft rapport. Het model beslist veel meer zelf."
|
||
|
||
`[SLIDE 5 — Anatomie van de loop]`
|
||
|
||
**Vertel:** "Wat gebeurt er per loop-iteratie, één stap? Vijf dingen.
|
||
|
||
Eén: we sturen de huidige messages naar het LLM. Twee: LLM antwoordt — óf met text, óf met een tool-call. Als het text is, stopt de loop. Dat is een natural finish — model heeft niks meer te doen. Als het een tool-call is, gaan we door. Drie: we voeren de tool execute uit. Vier: we voegen het resultaat toe aan de berichten. Vijf: we checken stop-conditie. Niet voldaan? Volgende iteratie.
|
||
|
||
De loop stopt in vier gevallen: text-only antwoord, stopWhen geactiveerd, tool zonder execute aangeroepen — dat is het 'done-pattern', komen we straks op terug, en tool die approval nodig heeft. Die laatste skippen we vandaag.
|
||
|
||
Default in v6: `stepCountIs(20)`. Dat is een veiligheidsgrens — voorkomt dat een rare bug in je systeem prompt resulteert in 200 API-calls en een rekening van 30 euro."
|
||
|
||
`[SLIDE 6 — ToolLoopAgent]`
|
||
|
||
**Vertel:** "In oudere AI SDK versies moest je de loop zelf schrijven — een while-loop rond `streamText` en `generateText`. In v6 is er een dedicated class: `ToolLoopAgent`.
|
||
|
||
`*[Wijs naar code op slide]*`
|
||
|
||
Je geeft model, system prompt, tools, en een stop-conditie. Eén keer definiëren. Dan `agent.generate({ prompt })` aanroepen, of `agent.stream` voor streaming.
|
||
|
||
Wat je terugkrijgt: `result.text` is het eindantwoord, `result.steps` is een array met alle stappen die de agent zette — welke tool aangeroepen, met welke input, welk resultaat. Dat is je debug-goud."
|
||
|
||
💬 *Vraag: "Kan dat nog steeds met streamText?"*
|
||
|
||
**Antwoord:** "Ja, voor backwards compatibility. Maar voor nieuwe code is `ToolLoopAgent` de aanrader. Minder boilerplate, herbruikbaar."
|
||
|
||
`[SLIDE 7 — Stop-condities]`
|
||
|
||
**Vertel:** "Vier soorten stop-condities.
|
||
|
||
Eén: `stepCountIs(N)` — stop na N stappen. Standaard veiligheidsmaatregel.
|
||
|
||
Twee: `hasToolCall('saveReport')` — stop zodra een specifieke tool is aangeroepen. Logisch voor 'doe het werk en sla op, dan klaar'.
|
||
|
||
Drie: `isLoopFinished()` — onbeperkt. Agent stopt alleen natuurlijk wanneer model klaar is. Dit is gevaarlijk zonder andere safeguards — agent kan in theorie oneindig doorgaan. Alleen gebruiken met cost limit erbij.
|
||
|
||
Vier: combineer ze in een array. Loop stopt zodra één conditie waar is.
|
||
|
||
En vijf — niet built-in maar zelf te schrijven: `StopCondition` callback. Daarmee kun je stoppen op kosten, op aantal tools, op wat dan ook dat je uit de step-historie kunt halen."
|
||
|
||
`[SLIDE 8 — prepareStep]`
|
||
|
||
**Vertel:** "`prepareStep` is misschien de krachtigste feature van `ToolLoopAgent`. Het is een async callback die VOOR elke stap draait. Je kunt het model dynamisch wisselen, tools beperken, messages trimmen.
|
||
|
||
Voorbeeld: begin met `gpt-4o-mini` — snel en goedkoop. Maar als de stap-historie complex wordt, switch naar Claude Sonnet voor beter reasoning. Dat scheelt geld zonder kwaliteit te verliezen.
|
||
|
||
Of: dwing een research-fase af. Stap 0-3 mag alleen `webSearch`. Stap 4-6 alleen `readPage`. Stap 7+ alleen `saveReport`. Agent kan niet 'cheaten' door te vroeg op te slaan.
|
||
|
||
Of: trim oude tool-results uit de berichten. Een lange agent kan 30k tokens aan tool-output verzamelen. Niet meer relevant voor de huidige stap — eruit, scheelt budget."
|
||
|
||
💬 *Vraag: "Hoe weet ik wanneer ik prepareStep nodig heb?"*
|
||
|
||
**Antwoord:** "Niet bij elke agent. Begin zonder. Als je merkt dat je agent veel geld kost, of te vaak in een verkeerde fase blijft hangen, dan kom je terug om `prepareStep` toe te voegen. Premature optimization is ook in agents reëel."
|
||
|
||
---
|
||
|
||
## BLOK 3 — Live Demo 1: Setup research-agent (25 min)
|
||
|
||
`[SLIDE 9 — Wat we bouwen]`
|
||
|
||
**Vertel:** "Genoeg theorie. We gaan een **research-agent** bouwen. Doel: gebruiker stelt een vraag — bijvoorbeeld 'wat zijn AI-trends in 2026?' — en de agent zoekt zelfstandig op het web, leest de relevante pagina's, schrijft een rapport, slaat op in Supabase.
|
||
|
||
Vier tools: `webSearch` met Tavily, `readPage` om URLs te lezen, `saveReport` om in DB te zetten, en eventueel een `done`-tool.
|
||
|
||
Stack: Next.js 16, AI SDK v6, Tavily, Supabase, gpt-4o-mini. Losse repo — niet de Polderfest-demo. Agents verdienen een schone context."
|
||
|
||
`[SLIDE 10 — LIVE DEMO 1]` `[SCHERM: terminal + editor]`
|
||
|
||
**Vertel:** "Klas, leun achterover. Vragen tussendoor mogen. Ik laat zien hoe je een agent from-scratch bouwt."
|
||
|
||
`*[Open terminal in /Les14-Agents]*`
|
||
|
||
```bash
|
||
pnpm create next-app@latest research-agent \
|
||
--typescript --tailwind --app --no-src-dir --import-alias "@/*"
|
||
cd research-agent
|
||
```
|
||
|
||
`*[Wacht op next-app, ondertussen praat door over Tavily]*`
|
||
|
||
**Vertel:** "Voor web search heb je een API nodig. OpenAI heeft sinds kort een eigen web-search tool, maar die werkt alleen met gpt-4o en is duur. Voor onafhankelijke search gebruiken we Tavily — gemaakt voor AI agents, gratis tier van 1000 calls per maand. Gewoon registreren met email, API key gepakt, klaar.
|
||
|
||
Andere opties: Brave Search API, Serper, Google Custom Search. Tavily is het simpelst voor onze case omdat het naast search ook content-extractie biedt."
|
||
|
||
```bash
|
||
pnpm add ai @ai-sdk/openai zod @supabase/supabase-js
|
||
```
|
||
|
||
`*[Open editor]*`
|
||
|
||
**Vertel:** "Eerst environment vars."
|
||
|
||
`*[Maak .env.local]*`
|
||
|
||
```
|
||
OPENAI_API_KEY=sk-...
|
||
TAVILY_API_KEY=tvly-...
|
||
NEXT_PUBLIC_SUPABASE_URL=...
|
||
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
|
||
```
|
||
|
||
**Vertel:** "Belangrijk: `OPENAI_API_KEY` is GEEN `NEXT_PUBLIC_*`. Anders zit-ie in je client-bundle en kan iedereen 'm zien in DevTools. Server-only. Hetzelfde voor `TAVILY_API_KEY`."
|
||
|
||
`*[Check .gitignore — moet .env.local in staan]*`
|
||
|
||
**Vertel:** "Check je `.gitignore`. `.env.local` moet erin. Standaard create-next-app doet dit goed maar — altijd checken."
|
||
|
||
`*[Supabase Dashboard openen]*` `[SCHERM: supabase]`
|
||
|
||
```sql
|
||
create table research_reports (
|
||
id bigserial primary key,
|
||
query text not null,
|
||
summary text not null,
|
||
sources jsonb default '[]'::jsonb,
|
||
created_at timestamp default now()
|
||
);
|
||
```
|
||
|
||
**Vertel:** "Tabel voor onze rapporten. `query` is de vraag, `summary` is het rapport zelf, `sources` is een JSON array van URLs die werden gebruikt."
|
||
|
||
```sql
|
||
alter table research_reports enable row level security;
|
||
create policy "demo open" on research_reports
|
||
for all to anon using (true) with check (true);
|
||
```
|
||
|
||
**Vertel:** "RLS aan, en een open policy voor demo. Productie: koppelen aan auth.uid()."
|
||
|
||
`*[Terug naar editor]*` `[SCHERM: editor]`
|
||
|
||
**Vertel:** "Nu de tools. Ik maak een `lib/tools.ts` met onze drie tools."
|
||
|
||
`*[Maak lib/tools.ts]*`
|
||
|
||
```typescript
|
||
import { tool } from "ai";
|
||
import { z } from "zod";
|
||
import { createClient } from "@supabase/supabase-js";
|
||
|
||
const supabase = createClient(
|
||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||
);
|
||
|
||
export const webSearch = tool({
|
||
description: "Zoek op het web. Returnt top 5 resultaten met url + snippet.",
|
||
inputSchema: z.object({
|
||
query: z.string().describe("Zoekterm"),
|
||
}),
|
||
execute: async ({ query }) => {
|
||
const res = await fetch("https://api.tavily.com/search", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
api_key: process.env.TAVILY_API_KEY,
|
||
query,
|
||
max_results: 5,
|
||
}),
|
||
});
|
||
const data = await res.json();
|
||
return data.results?.map((r: any) => ({
|
||
url: r.url,
|
||
title: r.title,
|
||
snippet: r.content,
|
||
})) ?? [];
|
||
},
|
||
});
|
||
```
|
||
|
||
**Vertel:** "Beschrijving van `webSearch` is kort en specifiek. AI leest description om te beslissen welke tool nodig is — vaag = verkeerde keuze. `max_results: 5` houdt het beheersbaar — Tavily kan ook 10 of 20, maar 5 is goed startpunt."
|
||
|
||
`*[readPage tool toevoegen]*`
|
||
|
||
```typescript
|
||
export const readPage = tool({
|
||
description: "Haal de volledige tekst van een URL op.",
|
||
inputSchema: z.object({ url: z.string().url() }),
|
||
execute: async ({ url }) => {
|
||
const res = await fetch("https://api.tavily.com/extract", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
api_key: process.env.TAVILY_API_KEY,
|
||
urls: [url],
|
||
}),
|
||
});
|
||
const data = await res.json();
|
||
const content = data.results?.[0]?.raw_content ?? "";
|
||
return { url, content: content.slice(0, 5000) };
|
||
},
|
||
});
|
||
```
|
||
|
||
**Vertel:** "`readPage` haalt de fulltext van een URL — Tavily heeft een extract-endpoint dat ruis verwijdert. Ik knip op 5000 chars om token-budget niet te exploderen. Voor productie: chunken + samenvatten."
|
||
|
||
`*[saveReport tool]*`
|
||
|
||
```typescript
|
||
export const saveReport = tool({
|
||
description:
|
||
"Sla een onderzoeksrapport op in de database. " +
|
||
"Alleen aanroepen als het rapport compleet is.",
|
||
inputSchema: z.object({
|
||
query: z.string(),
|
||
summary: z.string().min(100),
|
||
sources: z.array(z.object({
|
||
url: z.string().url(),
|
||
title: z.string(),
|
||
})),
|
||
}),
|
||
execute: async ({ query, summary, sources }) => {
|
||
const { data, error } = await supabase
|
||
.from("research_reports")
|
||
.insert({ query, summary, sources })
|
||
.select()
|
||
.single();
|
||
if (error) return { error: error.message };
|
||
return { reportId: data.id, success: true };
|
||
},
|
||
});
|
||
```
|
||
|
||
**Vertel:** "`saveReport` is onze enige write-tool. Description benadrukt: alleen aanroepen als compleet. `summary.min(100)` — zod schema dwingt minimum lengte af, AI mag geen 'TODO' insturen."
|
||
|
||
`*[lib/agent.ts maken]*`
|
||
|
||
**Vertel:** "Nu de agent zelf."
|
||
|
||
```typescript
|
||
import { ToolLoopAgent, stepCountIs } from "ai";
|
||
import { openai } from "@ai-sdk/openai";
|
||
import { webSearch, readPage, saveReport } from "./tools";
|
||
|
||
export const researchAgent = new ToolLoopAgent({
|
||
model: openai("gpt-4o-mini"),
|
||
system: `Je bent een research-assistent. Werk als volgt:
|
||
1. Begin met een kort plan (3-5 bullets).
|
||
2. Gebruik webSearch voor oriëntatie.
|
||
3. Gebruik readPage voor de 2-3 meest relevante URLs.
|
||
4. Schrijf een samenvatting van min 200 woorden.
|
||
5. Sla op met saveReport. Inclusief alle gebruikte sources.
|
||
|
||
Verzin niets — gebruik alleen wat je via tools hebt opgehaald.`,
|
||
tools: { webSearch, readPage, saveReport },
|
||
stopWhen: stepCountIs(10),
|
||
});
|
||
```
|
||
|
||
**Vertel:** "System prompt is **het** kritieke onderdeel. Ik geef expliciete fasen — plan, search, read, samenvat, save. Hoe duidelijker, hoe minder de agent drifft. 'Verzin niets' is belangrijk — anders maakt AI bronnen op."
|
||
|
||
`*[app/api/research/route.ts]*`
|
||
|
||
```typescript
|
||
import { researchAgent } from "@/lib/agent";
|
||
|
||
export async function POST(req: Request) {
|
||
const { query } = await req.json();
|
||
const result = await researchAgent.generate({ prompt: query });
|
||
|
||
return Response.json({
|
||
text: result.text,
|
||
steps: result.steps.map((s, i) => ({
|
||
step: i,
|
||
toolCalls: s.toolCalls?.map((tc) => ({
|
||
tool: tc.toolName,
|
||
input: tc.input,
|
||
})),
|
||
hasText: !!s.text,
|
||
})),
|
||
});
|
||
}
|
||
```
|
||
|
||
**Vertel:** "Endpoint stuurt query naar de agent, returnt text + step-samenvatting. Voor productie zou je dit streamen, voor demo houden we het simpel — <20><>én POST, JSON response."
|
||
|
||
`*[Run]*` `[SCHERM: terminal]`
|
||
|
||
```bash
|
||
pnpm dev
|
||
```
|
||
|
||
`*[In tweede terminal:]*`
|
||
|
||
```bash
|
||
curl -X POST http://localhost:3000/api/research \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"query": "Wat zijn de belangrijkste AI-trends in 2026?"}' \
|
||
| jq
|
||
```
|
||
|
||
**Vertel:** "We zien... `*[wachten 20-40s]*` ...de agent draait. JSON komt terug. Kijk — `steps` array. webSearch, dan readPage, readPage, saveReport. Vier stappen. `text` veld is een samenvatting."
|
||
|
||
`*[Open Supabase, check tabel]*` `[SCHERM: supabase]`
|
||
|
||
**Vertel:** "Rapport staat in Supabase. Werkend agent. From scratch in 20 minuten."
|
||
|
||
💬 *Vraag: "Hoeveel tokens kost zo'n call?"*
|
||
|
||
**Antwoord:** "Goeie vraag. `result.usage` heeft `inputTokens` en `outputTokens`. Voor zo'n research van 4 stappen: ongeveer 8.000 input + 1.500 output tokens. Op gpt-4o-mini: ~0.001 dollar. Bijna gratis. Op gpt-4o: ~0.05 dollar. Houdbaar."
|
||
|
||
---
|
||
|
||
## BLOK 4 — Live Demo 2: stopWhen variants (25 min)
|
||
|
||
`[SLIDE 11 — DEMO 2]` `[SCHERM: editor]`
|
||
|
||
**Vertel:** "We hebben nu een agent met `stepCountIs(10)`. Laten we de andere stop-condities uitproberen en zien wat het verschil maakt."
|
||
|
||
`*[Wijzig stopWhen]*`
|
||
|
||
```typescript
|
||
import { ToolLoopAgent, stepCountIs, hasToolCall } from "ai";
|
||
|
||
// ...
|
||
stopWhen: hasToolCall("saveReport"),
|
||
```
|
||
|
||
**Vertel:** "Nu stopt agent **zodra** saveReport is aangeroepen. Geen veiligheidscap. Laten we draaien."
|
||
|
||
`*[Curl draaien]*`
|
||
|
||
**Vertel:** "Werkt. Agent doet zijn werk, roept saveReport, stopt. Voordeel: geen onnodige extra stappen. Nadeel: als saveReport faalt en agent retried, kan-ie alsnog lang doorgaan."
|
||
|
||
`*[Beter: combineren]*`
|
||
|
||
```typescript
|
||
stopWhen: [
|
||
stepCountIs(20),
|
||
hasToolCall("saveReport"),
|
||
],
|
||
```
|
||
|
||
**Vertel:** "Best practice: array. Veiligheidscap én task-completion. Loop stopt op de eerste van de twee."
|
||
|
||
`*[Probeer isLoopFinished]*`
|
||
|
||
```typescript
|
||
import { isLoopFinished } from "ai";
|
||
// ...
|
||
stopWhen: isLoopFinished(),
|
||
```
|
||
|
||
**Vertel:** "`isLoopFinished` is letterlijk: geen limit. Alleen natuurlijke finish stopt het. Dit is **gevaarlijk** zonder andere safeguards. Een misverstand in je system prompt en agent draait 50 stappen door."
|
||
|
||
`*[Curl draaien — agent doet nu 8-10 stappen]*`
|
||
|
||
**Vertel:** "Zie je? Agent gaat door tot het zelf besluit te stoppen. Eindigt met text — geen tool-call. Werkt, maar — wat als prompt slecht is, of als API een rare fout gooit en de agent retried?"
|
||
|
||
`*[Custom StopCondition]*`
|
||
|
||
**Vertel:** "Eigen stop. Bijvoorbeeld stoppen als we genoeg pagina's hebben gelezen."
|
||
|
||
```typescript
|
||
import type { StopCondition } from "ai";
|
||
import type { researchAgent } from "./agent";
|
||
|
||
type Tools = typeof researchAgent extends ToolLoopAgent<infer T> ? T : never;
|
||
|
||
const enoughReading: StopCondition<any> = ({ steps }) => {
|
||
const readCount = steps.flatMap((s) =>
|
||
s.toolCalls?.filter((tc) => tc.toolName === "readPage") ?? []
|
||
).length;
|
||
return readCount >= 3;
|
||
};
|
||
|
||
stopWhen: [
|
||
stepCountIs(20),
|
||
enoughReading,
|
||
],
|
||
```
|
||
|
||
**Vertel:** "Drie pagina's lezen is genoeg, stoppen. Custom logic die uit step-historie wordt gehaald."
|
||
|
||
`*[Demo run]*`
|
||
|
||
**Vertel:** "Zie: agent stopt op stap waar readPage voor de derde keer wordt aangeroepen. Conditie werkt. Voor productie gebruik je dit vaak voor kostenlimieten — daar komen we morgen op terug, eh, in het huiswerk."
|
||
|
||
💬 *Vraag: "Waarom `: StopCondition<any>` en niet typed?"*
|
||
|
||
**Antwoord:** "Excuus, dat is een shortcut. In productie zou je een `ToolSet` type extracten zodat je `StopCondition<typeof tools>` krijgt — dan kun je tool-names typesafe checken. Voor demo houd ik 't simpel."
|
||
|
||
---
|
||
|
||
## BLOK 5 — Pauze (15 min)
|
||
|
||
`[SLIDE 12 — Pauze]`
|
||
|
||
**Vertel:** "Pauze. Vijftien minuten. Loop een ronde."
|
||
|
||
`*[Reset agent.ts naar stepCountIs(15) + hasToolCall("saveReport") combo voor volgende demo]*`
|
||
|
||
---
|
||
|
||
## BLOK 6 — Live Demo 3: prepareStep (25 min)
|
||
|
||
`[SLIDE 13 — DEMO 3]` `[SCHERM: editor]`
|
||
|
||
**Vertel:** "Nu het krachtigste stuk: `prepareStep`. Voor elke stap kunnen we model, tools, messages aanpassen. Drie use cases vandaag."
|
||
|
||
### Use case 1 — Dynamic model
|
||
|
||
```typescript
|
||
prepareStep: async ({ stepNumber, messages }) => {
|
||
console.log(`Stap ${stepNumber}, ${messages.length} messages`);
|
||
if (stepNumber > 3) {
|
||
console.log("→ switch naar gpt-4o");
|
||
return { model: openai("gpt-4o") };
|
||
}
|
||
return {};
|
||
},
|
||
```
|
||
|
||
**Vertel:** "Stap 0-3 doen we met gpt-4o-mini — goedkoop en snel. Vanaf stap 4 switchen we naar de grote gpt-4o. Reden: vroege stappen zijn 'zoek even', late stappen zijn 'vat samen en redeneer'. Dat laatste verdient een sterker model."
|
||
|
||
`*[Curl draaien — kijk in console naar logs]*`
|
||
|
||
**Vertel:** "Zien jullie: 'Stap 0 — mini'. 'Stap 1 — mini'. 'Stap 4 — switch naar gpt-4o'. Dynamic. Effect: lagere kosten, betere eindkwaliteit."
|
||
|
||
### Use case 2 — Fase-gebaseerde tools
|
||
|
||
`*[Pas aan]*`
|
||
|
||
```typescript
|
||
prepareStep: async ({ stepNumber }) => {
|
||
if (stepNumber <= 2) {
|
||
return { activeTools: ["webSearch"] };
|
||
}
|
||
if (stepNumber <= 5) {
|
||
return { activeTools: ["readPage"] };
|
||
}
|
||
return {
|
||
activeTools: ["saveReport"],
|
||
toolChoice: "required",
|
||
};
|
||
},
|
||
```
|
||
|
||
**Vertel:** "Nu dwingen we een fase af. Stap 0-2 mag alleen searchen. Stap 3-5 alleen lezen. Stap 6+ alleen opslaan, en moet die ook gebruiken want `toolChoice: required`."
|
||
|
||
`*[Curl draaien]*`
|
||
|
||
**Vertel:** "Agent kan niet 'cheaten'. Geen `saveReport` op stap 2 met een vage samenvatting. Eerst werken, dan opslaan."
|
||
|
||
💬 *Vraag: "Is dat niet een beetje vechten tegen het model?"*
|
||
|
||
**Antwoord:** "Het is een trade-off. Meer controle = minder flexibel. Voor research is een gefaseerde aanpak vaak prima. Voor open-ended problem solving — bijv. een coding agent — zou ik 't niet doen. Daar moet het model vrij kunnen kiezen."
|
||
|
||
### Use case 3 — Context-trimming
|
||
|
||
```typescript
|
||
prepareStep: async ({ messages, stepNumber }) => {
|
||
if (messages.length > 12) {
|
||
return {
|
||
messages: [
|
||
messages[0], // system
|
||
...messages.slice(-8) // laatste 8
|
||
],
|
||
};
|
||
}
|
||
return {};
|
||
},
|
||
```
|
||
|
||
**Vertel:** "Bij lange agents groeit `messages` snel — 30+ items, elk met tool-results van duizenden tokens. Hier zeggen we: na 12 messages, gooi de oudste weg, hou alleen system + laatste 8.
|
||
|
||
Voor research specifiek niet zo nodig — meestal eindigt agent na 8-10 stappen. Maar voor coding agents of debugging-agents die 30+ stappen doen — essentieel."
|
||
|
||
`*[Test]*`
|
||
|
||
**Vertel:** "Voor onze research-agent niet zichtbaar effect. Maar nu je het weet: bij langere agents — gebruik je dit."
|
||
|
||
---
|
||
|
||
## BLOK 7 — Live Demo 4: Done-tool + custom stop (20 min)
|
||
|
||
`[SLIDE 14 — DEMO 4]` `[SCHERM: editor]`
|
||
|
||
**Vertel:** "Nog twee patronen. Eerst de **done-tool**. Een tool **zonder execute**."
|
||
|
||
```typescript
|
||
export const done = tool({
|
||
description:
|
||
"Roep aan wanneer onderzoek klaar is en rapport is opgeslagen. " +
|
||
"Geef het reportId en een korte samenvatting.",
|
||
inputSchema: z.object({
|
||
reportId: z.number(),
|
||
summary: z.string(),
|
||
}),
|
||
// GEEN execute — dat stopt de loop
|
||
});
|
||
```
|
||
|
||
**Vertel:** "Geen execute betekent: als agent deze tool aanroept, stopt de loop automatisch. Geen tool-result toegevoegd, geen volgende stap.
|
||
|
||
Combineren met `toolChoice: 'required'` — dwingen dat agent altijd via een tool stopt, nooit met losse text."
|
||
|
||
```typescript
|
||
export const researchAgent = new ToolLoopAgent({
|
||
model: openai("gpt-4o-mini"),
|
||
system: `... [zelfde] ...
|
||
6. Wanneer alles opgeslagen is, roep dan 'done' aan met het reportId.`,
|
||
tools: { webSearch, readPage, saveReport, done },
|
||
toolChoice: "required",
|
||
stopWhen: stepCountIs(15),
|
||
});
|
||
```
|
||
|
||
`*[Run]*`
|
||
|
||
**Vertel:** "Agent doet zijn werk, eindigt met `done({ reportId: 42, summary: '...' })`. We pakken het eindresultaat uit `result.staticToolCalls`:"
|
||
|
||
```typescript
|
||
const result = await researchAgent.generate({ prompt: query });
|
||
const doneCall = result.staticToolCalls[0];
|
||
if (doneCall?.toolName === "done") {
|
||
console.log("Final:", doneCall.input.summary);
|
||
}
|
||
```
|
||
|
||
**Vertel:** "Voordeel: gestructureerd eindantwoord. Geen vage text. Voor pipelines waar je het resultaat ergens anders moet injecten — heel handig."
|
||
|
||
### Custom stop op kosten
|
||
|
||
`*[Voeg toe]*`
|
||
|
||
```typescript
|
||
const costLimit: StopCondition<any> = ({ steps }) => {
|
||
const tokens = steps.reduce(
|
||
(sum, s) => sum + (s.usage?.totalTokens ?? 0),
|
||
0
|
||
);
|
||
const usd = (tokens * 0.00015) / 1000; // gpt-4o-mini rate
|
||
console.log(`Total tokens: ${tokens}, ~$${usd.toFixed(4)}`);
|
||
return usd > 0.05;
|
||
};
|
||
|
||
stopWhen: [stepCountIs(30), costLimit],
|
||
```
|
||
|
||
**Vertel:** "Stop als kosten boven 5 dollarcent uitkomen. Voor productie: kritiek. Eén bug in je prompt en je hebt 100 calls — zonder cost cap is dat 20 euro. Mét cap: maximaal 5 cent per run, klaar."
|
||
|
||
### Plan-Act-Reflect
|
||
|
||
`[SLIDE — terug naar slide 14 visual]`
|
||
|
||
**Vertel:** "Laatste pattern — alleen kort toelichten. ReAct: Reason + Act. Idee: in de system prompt vraag je expliciet om eerst een plan, dan acties, dan reflectie.
|
||
|
||
System prompt:
|
||
```
|
||
1. Begin met een plan in 3-5 bullets.
|
||
2. Voer plan uit met tools.
|
||
3. Eindig met korte reflectie — wat zou je anders doen?
|
||
```
|
||
|
||
Geen extra code nodig — het model doet dit vanzelf binnen de loop. Effect: tracebaarder gedrag, en zelf-correctie in latere stappen.
|
||
|
||
Voor sub-agents — een agent die een andere agent als tool gebruikt — schuiven we naar Les 14, want we hebben tijdsdruk."
|
||
|
||
---
|
||
|
||
## BLOK 8 — Wanneer wel, wanneer niet (10 min)
|
||
|
||
`[SLIDE 15 — Agent vs tool-call vs workflow]`
|
||
|
||
**Vertel:** "Drie niveaus van AI-autonomie. Plain tool-call: 1 tool, 1 antwoord. Multi-step uit Les 12: 2-5 tools, 1 doel. Agent uit Les 14: 10-50 stappen, open-ended. En het hoogste niveau van **controle** — een explicit workflow. Code die exact bepaalt welke tool wanneer.
|
||
|
||
`*[Wijs tabel op slide]*`
|
||
|
||
Wanneer GEEN agent. Determinisme nodig — finance, juridisch, medische data. Geen agent. Doe het in code. Latency belangrijk — agent kost al snel 30 seconden. Voor user-facing chat: te traag. Voorspelbare kosten — agent met 50 stappen kan duur zijn.
|
||
|
||
Wanneer WEL agent. Open-ended — research, planning, debugging. Volgorde onbekend — model moet zelf bepalen. Async gebruik — backend job, niet realtime UI.
|
||
|
||
Quote uit de docs: *Agents are flexible and powerful, but non-deterministic.*"
|
||
|
||
💬 *Vraag: "Wat is een workflow dan precies in code?"*
|
||
|
||
**Antwoord:** "Gewone JavaScript. If-statements, switches, expliciete functies. Bijvoorbeeld:
|
||
|
||
```typescript
|
||
async function processOrder(order) {
|
||
const validated = await validate(order);
|
||
if (!validated.ok) return reject(validated.errors);
|
||
const enriched = await enrichWithAI(validated.data);
|
||
await save(enriched);
|
||
await notify(enriched);
|
||
}
|
||
```
|
||
|
||
Dat is een workflow. Reproduceerbaar, debuggable, testbaar. Een agent zou dit ook kunnen, maar je weet niet exact wat-ie doet — non-deterministisch."
|
||
|
||
---
|
||
|
||
## BLOK 9 — Lesopdracht + Huiswerk (15 min)
|
||
|
||
`[SLIDE 16 — Lesopdracht + Huiswerk]`
|
||
|
||
**Vertel:** "Lesopdracht. Vandaag in de les: setup van de research-agent zoals ik 'm vandaag liet zien. Tavily account, Supabase tabel, 3 tools, agent, één test. Half uur — kun je halen voor we klaar zijn.
|
||
|
||
Voor huiswerk: uitbreiden. Vier dingen.
|
||
|
||
Eén: 4e tool `listReports` — om eerder opgeslagen rapporten op te halen. System prompt aanpassen zodat agent eerst checkt of er al onderzoek is.
|
||
|
||
Twee: `prepareStep` toevoegen. Kies één use case — dynamic model, fase-tools, of context-trimming. Documenteer wat je koos en waarom.
|
||
|
||
Drie: custom `StopCondition`. Token-budget, of aantal pagina's gelezen, of iets eigens.
|
||
|
||
Vier: schrijf een `AGENT.md` in repo-root. Tool-lijst, stop-strategie, prepareStep-keuze, één voorbeeld-run met logs, één observatie.
|
||
|
||
Bonus voor wie wil: UI met live step-rendering, of een sub-agent voor samenvatting.
|
||
|
||
Beoordeling: 10 punten totaal, voldoende is 6+. Inleveren via Brightspace met repo URL voor Les 14."
|
||
|
||
---
|
||
|
||
## BLOK 10 — Afsluiting (5 min)
|
||
|
||
`[SLIDE 17 — Afsluiting]`
|
||
|
||
**Vertel:** "Wat hebben we vandaag gedaan. Een agent is: LLM in een loop met tools. We gebruikten `ToolLoopAgent` om de loop te managen. Vier stop-condities — stepCountIs, hasToolCall, isLoopFinished, custom. `prepareStep` voor dynamic model, tools, context. Done-tool pattern voor gestructureerde eindantwoorden. En we wisten wanneer een agent wel/niet de juiste tool is.
|
||
|
||
Volgende les: RAG. Embeddings. Vector search in pgvector. Combo met agents wordt heel sterk — een 'searchKnowledgeBase' tool die semantic search doet, in een research-agent.
|
||
|
||
Daarna: testing, deployment, performance, eindopdracht.
|
||
|
||
Vragen?"
|
||
|
||
`*[Vragenronde — minstens 5 minuten over laten]*`
|
||
|
||
---
|
||
|
||
## NA DE LES — Wrap-up
|
||
|
||
- Push working `research-agent` repo naar GitHub als referentie
|
||
- Brightspace: link naar deze repo + huiswerk-instructie
|
||
- Verzamel vragen die niet beantwoord — neem mee in Les 14 opening
|
||
- Note voor Klas B (later): Klas B doet dit waarschijnlijk in week na RAG, niet ervoor — orde aanpassen indien nodig
|
||
|
||
---
|
||
|
||
## Veelvoorkomende fouten tijdens live coding
|
||
|
||
| Fout | Oplossing |
|
||
|------|-----------|
|
||
| `TAVILY_API_KEY undefined` | `pnpm dev` opnieuw starten — env wordt bij start ingelezen |
|
||
| Tavily 401 | Verkeerde key — check dashboard tavily.com |
|
||
| `result.steps` is undefined | Je gebruikt oude v5 API — check je `ai` versie is `^6` |
|
||
| `ToolLoopAgent` import faalt | `pnpm update ai` — moet 6.0+ zijn |
|
||
| Agent stopt na 1 stap | `stopWhen` te restrictief, of system prompt zegt niet duidelijk wat te doen |
|
||
| Tool returnt `[Object]` | `JSON.stringify` voor logs — Tavily resultaat is genest |
|
||
| Supabase RLS error | Open policy nog niet aangemaakt, of `enable rls` ontbreekt |
|
||
| Cost-cap stop werkt niet | Check of `step.usage` echt populated is — sommige providers geven het niet altijd terug |
|
||
|
||
---
|
||
|
||
## Mentale model voor de klas
|
||
|
||
Als studenten verward zijn over "wanneer is iets een agent vs tool-call":
|
||
|
||
> **Tool-calling** (Les 12) = "AI mag tot 5 keer een tool aanroepen om je vraag te beantwoorden."
|
||
>
|
||
> **Agent** (Les 14) = "AI krijgt een DOEL, mag autonoom plannen en 20-50 stappen doen, en levert pas op als-ie zelf vindt dat het klaar is."
|
||
|
||
Het verschil is dus **doelgericht autonoom werken**, niet zomaar 'meer stappen'.
|