From 96bcad072adf8d7c670fe46c322887a99b0f1711 Mon Sep 17 00:00:00 2001 From: yagop Date: Sun, 14 Jun 2026 22:59:46 +0000 Subject: [PATCH] Implement #5: Implementing Tools and Function Calling Chapter 5 plus its examples/05-tools/ samples: declaring a tool, catching the tool_use block, the full create -> tool_use -> tool_result -> answer round-trip, dispatching block.name to typed handlers, returning is_error results, and steering with tool_choice. Wires the chapter into the VitePress sidebar and README. Co-Authored-By: Claude Opus 4.8 --- .vitepress/config.ts | 4 ++ README.md | 1 + chapters/05-tools.md | 69 +++++++++++++++++++++++ examples/05-tools/define-tool.ts | 45 +++++++++++++++ examples/05-tools/dispatch-handlers.ts | 78 ++++++++++++++++++++++++++ examples/05-tools/single-tool-loop.ts | 49 ++++++++++++++++ examples/05-tools/tool-choice.ts | 44 +++++++++++++++ examples/05-tools/tool-errors.ts | 68 ++++++++++++++++++++++ 8 files changed, 358 insertions(+) create mode 100644 chapters/05-tools.md create mode 100644 examples/05-tools/define-tool.ts create mode 100644 examples/05-tools/dispatch-handlers.ts create mode 100644 examples/05-tools/single-tool-loop.ts create mode 100644 examples/05-tools/tool-choice.ts create mode 100644 examples/05-tools/tool-errors.ts diff --git a/.vitepress/config.ts b/.vitepress/config.ts index a632bc5..16411c9 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -64,6 +64,10 @@ export default { text: '4. Context and Conversation Management', link: '/chapters/04-context', }, + { + text: '5. Implementing Tools and Function Calling', + link: '/chapters/05-tools', + }, ], }, ], diff --git a/README.md b/README.md index c5309f2..843d8c9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Published with VitePress at https://yagop.github.io/coding-agents-tutorial/. Eac 2. [Streaming Responses and Message Types](chapters/02-streaming.md) 3. [Handling User Requests: REPL and Telegram Bot](chapters/03-repl-telegram.md) 4. [Context and Conversation Management](chapters/04-context.md) +5. [Implementing Tools and Function Calling](chapters/05-tools.md) More chapters are tracked as issues and land here as they are written. diff --git a/chapters/05-tools.md b/chapters/05-tools.md new file mode 100644 index 0000000..2b3f9dc --- /dev/null +++ b/chapters/05-tools.md @@ -0,0 +1,69 @@ +# Implementing Tools and Function Calling + +🔧 Everything you have built so far lives entirely inside the conversation: words in, words out. Tools are how your agent reaches past that wall and touches the real world - reading a file, calling an API, running a query. Here is the part worth getting straight before any code: the model never runs your code. It only *emits* a structured request to call a tool, and then *reads* a structured result you hand back. You own every step in between. Master this one round-trip - declare a tool, catch the request, run it, return the result - and every multi-tool workflow in later chapters is just this loop again. + +You already have `new Anthropic()`, the env vars, content-block narrowing, and the `messages` array from Chapters 1-2 (Chapter 4 helps too), so here you only add the tool layer. As always the key comes from the environment - never hardcode it - and Bun auto-loads `.env`, so there is nothing to import. + +## Declaring a tool and catching the call + +A tool is a small contract you give the model: a `name`, a `description` of when to use it, and an `input_schema` - JSON Schema with `type`, `properties`, `required`, and `enum` for fixed value sets. You declare it with the SDK's own `Anthropic.Tool` type, not a hand-rolled interface, and pass it in the `tools` array. When the model decides to call it, it returns a `tool_use` content block carrying an `id`, the `name`, and an `input`. + +<<< @/examples/05-tools/define-tool.ts + +Run it to watch a `get_weather` call come back: + +```sh +bun run examples/05-tools/define-tool.ts +``` + +Notice that you find the call by narrowing `response.content` to the block whose `type === 'tool_use'` - an `Anthropic.ToolUseBlock` - exactly the way you narrowed `text` blocks before. + +::: warning The one gotcha worth tattooing on your wrist +`block.input` arrives **already parsed** as an object from the SDK. Do not `JSON.parse` it, and never raw-string-match the serialized JSON to pull out an argument - read `block.input.location` like any plain object property. +::: + +## Closing the loop with tool_result + +Catching the call is half the round-trip; now you close it. The flow is a fixed five-step dance, and it is worth holding the whole shape in your head before you read the code: + +```text +create(tools) -> stop_reason 'tool_use' -> run the tool locally + -> create again with a tool_result -> final text answer +``` + +When `stop_reason === 'tool_use'`, you append the assistant's content **verbatim** as an assistant turn, run the tool yourself, then send a new `user` turn whose `content` is a `tool_result` block - an `Anthropic.ToolResultBlockParam` that echoes the `tool_use_id` exactly. That echo is the rule that binds request to answer: every `tool_use` block in a turn needs a matching `tool_result`, or the next `create` call rejects the array. + +<<< @/examples/05-tools/single-tool-loop.ts + +The second `create` carries the assistant turn and your `tool_result` back, so the model reads the weather you fetched and writes a final sentence - and this time `stop_reason` comes back `end_turn` because the model is done. (You will meet the other `stop_reason` values as the chapters need them.) + +## Dispatching and recovering from errors + +Real agents carry more than one tool, so a friendlier pattern is a map from `block.name` to a typed handler. You read `block.input` as the parsed object it already is, call the handler that matches the name, and build each `tool_result` from what the handler returns. + +<<< @/examples/05-tools/dispatch-handlers.ts + +Each handler owns one tool, and the dispatch map is the only thing that grows as you add more - the loop around it never changes. The handlers return the `tool_result` content the model reads next. + +Handlers meet messy input, so they must not throw. When a tool's parsed arguments are wrong, return a `tool_result` with `is_error: true` and an informative message instead of crashing - the model reads the message and can correct itself or retry on the next turn. + +<<< @/examples/05-tools/tool-errors.ts + +The error result rides back in the exact same `tool_result` shape as a success; only `is_error: true` and the explaining message differ. A thrown exception kills your process and tells the model nothing - this hands it a chance to recover. + +## Steering which tool the model picks + +You will spend more time on descriptions than on schemas, and it pays off: the model chooses a tool almost entirely from your `description`. Lead with "call this when..." phrasing, give every property its own description, and use `enum` to pin a field to a fixed set so the model cannot invent a value. When you need a firmer hand, `tool_choice` overrides the model's own judgment. + +| `tool_choice` | Effect | +| --- | --- | +| `{ type: 'auto' }` | Model decides whether to call a tool (the default). | +| `{ type: 'any' }` | Model must call some tool, its pick. | +| `{ type: 'tool', name }` | Model must call this exact tool. | +| `{ type: 'none' }` | Model may not call any tool. | + +<<< @/examples/05-tools/tool-choice.ts + +Each mode is a single line of config, and because this prompt plainly needs the weather tool, every mode here calls it - the modes only pull apart on a prompt the model could answer in plain text, where `auto` stays text while `any` and `{ type: 'tool' }` force the call. Adding `disable_parallel_tool_use: true` caps the model at one tool call per turn - the simplest way to keep this chapter's single round-trip single while you find your footing. + +What's next: Chapter 6 - Building Tool Chains and Complex Workflows. \ No newline at end of file diff --git a/examples/05-tools/define-tool.ts b/examples/05-tools/define-tool.ts new file mode 100644 index 0000000..28c8b0b --- /dev/null +++ b/examples/05-tools/define-tool.ts @@ -0,0 +1,45 @@ +// bun run examples/05-tools/define-tool.ts + +import Anthropic from '@anthropic-ai/sdk'; + +const client = new Anthropic(); +const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6'; + +// name, "call this when..." description, and a JSON Schema input_schema are the whole contract. +const getWeather: Anthropic.Tool = { + name: 'get_weather', + description: 'Call this when the user asks about current weather in a specific place.', + input_schema: { + type: 'object', + properties: { + location: { type: 'string', description: 'City and region, e.g. "Paris, France".' }, + unit: { type: 'string', enum: ['celsius', 'fahrenheit'], description: 'Temperature unit.' }, + }, + required: ['location'], + }, +}; + +// The SDK types block.input loosely, so cast it to the shape this tool produces. +type WeatherInput = { location: string; unit?: 'celsius' | 'fahrenheit' }; + +const message = await client.messages.create({ + model, + max_tokens: 512, + tools: [getWeather], + messages: [{ role: 'user', content: 'What is the weather like in Tokyo right now?' }], +}); + +console.log('stop_reason:', message.stop_reason); + +// Narrow on type to pick the tool_use block out of the content array. +const toolUse = message.content.find( + (block): block is Anthropic.ToolUseBlock => block.type === 'tool_use', +); + +if (toolUse) { + // block.input arrives already parsed from the SDK - cast it, never JSON.parse it. + const input = toolUse.input as WeatherInput; + console.log('id:', toolUse.id); + console.log('name:', toolUse.name); + console.log('input:', input); +} diff --git a/examples/05-tools/dispatch-handlers.ts b/examples/05-tools/dispatch-handlers.ts new file mode 100644 index 0000000..a1209e0 --- /dev/null +++ b/examples/05-tools/dispatch-handlers.ts @@ -0,0 +1,78 @@ +// bun run examples/05-tools/dispatch-handlers.ts + +import Anthropic from '@anthropic-ai/sdk'; + +const client = new Anthropic(); +const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6'; + +const tools: Anthropic.Tool[] = [ + { + name: 'get_weather', + description: 'Call this when the user asks about current weather in a city.', + input_schema: { + type: 'object', + properties: { city: { type: 'string', description: 'City name, e.g. Madrid.' } }, + required: ['city'], + }, + }, + { + name: 'add', + description: 'Call this to add two numbers together.', + input_schema: { + type: 'object', + properties: { + a: { type: 'number', description: 'First addend.' }, + b: { type: 'number', description: 'Second addend.' }, + }, + required: ['a', 'b'], + }, + }, +]; + +// One typed shape per tool input. The SDK types block.input loosely, so each +// handler casts it to the matching shape before reading fields. +type WeatherInput = { city: string }; +type AddInput = { a: number; b: number }; + +// A handler takes the already-parsed block.input object and returns tool_result content. +type Handler = (input: Anthropic.ToolUseBlock['input']) => string; + +// One entry per tool name; this map is the only thing that grows as you add tools. +const handlers = new Map([ + ['get_weather', (input) => `It is 21C and sunny in ${(input as WeatherInput).city}.`], + ['add', (input) => { + const { a, b } = input as AddInput; + return String(a + b); + }], +]); + +const messages: Anthropic.MessageParam[] = [ + { role: 'user', content: 'What is the weather in Madrid, and what is 19 plus 23?' }, +]; + +const message = await client.messages.create({ model, max_tokens: 512, tools, messages }); + +if (message.stop_reason === 'tool_use') { + messages.push({ role: 'assistant', content: message.content }); + + const results: Anthropic.ToolResultBlockParam[] = []; + for (const block of message.content) { + if (block.type !== 'tool_use') continue; + const handler = handlers.get(block.name); + // Every tool_use needs a matching tool_result, so report a missing handler as an error. + if (!handler) { + const content = `No handler registered for tool ${block.name}.`; + results.push({ type: 'tool_result', tool_use_id: block.id, content, is_error: true }); + continue; + } + // block.input is already a parsed object from the SDK - never JSON.parse it. + const answer = handler(block.input); + results.push({ type: 'tool_result', tool_use_id: block.id, content: answer }); + } + + messages.push({ role: 'user', content: results }); + const final = await client.messages.create({ model, max_tokens: 512, tools, messages }); + + const text = final.content.find((block) => block.type === 'text'); + console.log('claude:', text?.text ?? ''); +} diff --git a/examples/05-tools/single-tool-loop.ts b/examples/05-tools/single-tool-loop.ts new file mode 100644 index 0000000..a10e05c --- /dev/null +++ b/examples/05-tools/single-tool-loop.ts @@ -0,0 +1,49 @@ +// bun run examples/05-tools/single-tool-loop.ts + +import Anthropic from '@anthropic-ai/sdk'; + +const client = new Anthropic(); +const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6'; + +const tools: Anthropic.Tool[] = [ + { + name: 'get_weather', + description: 'Call this when the user asks about current weather in a city.', + input_schema: { + type: 'object', + properties: { city: { type: 'string', description: 'City name, e.g. Madrid' } }, + required: ['city'], + }, + }, +]; + +type WeatherInput = { city: string }; + +function getWeather(input: WeatherInput): string { + return `It is 21C and sunny in ${input.city}.`; +} + +const messages: Anthropic.MessageParam[] = [ + { role: 'user', content: 'What is the weather in Madrid right now?' }, +]; + +const first = await client.messages.create({ model, max_tokens: 512, tools, messages }); + +if (first.stop_reason === 'tool_use') { + // Append the assistant turn verbatim - the model needs to see its own tool_use block. + messages.push({ role: 'assistant', content: first.content }); + + const results: Anthropic.ToolResultBlockParam[] = []; + for (const block of first.content) { + if (block.type !== 'tool_use') continue; + // block.input is already a parsed object from the SDK - never string-match the raw JSON. + const answer = getWeather(block.input as WeatherInput); + results.push({ type: 'tool_result', tool_use_id: block.id, content: answer }); + } + + messages.push({ role: 'user', content: results }); + + const second = await client.messages.create({ model, max_tokens: 512, tools, messages }); + const text = second.content.find((b) => b.type === 'text'); + console.log(text?.text ?? ''); +} diff --git a/examples/05-tools/tool-choice.ts b/examples/05-tools/tool-choice.ts new file mode 100644 index 0000000..bf68f60 --- /dev/null +++ b/examples/05-tools/tool-choice.ts @@ -0,0 +1,44 @@ +// bun run examples/05-tools/tool-choice.ts + +import Anthropic from '@anthropic-ai/sdk'; + +const client = new Anthropic(); +const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6'; + +const tools: Anthropic.Tool[] = [ + { + name: 'get_weather', + description: 'Call this when the user asks about weather in a city.', + input_schema: { + type: 'object', + properties: { city: { type: 'string', description: 'City name, e.g. Madrid.' } }, + required: ['city'], + }, + }, +]; + +const prompt = 'What is the weather in Madrid?'; + +// Same prompt, four ways of steering the model toward (or away from) the tool. +const choices: { label: string; tool_choice: Anthropic.ToolChoice }[] = [ + { label: 'auto', tool_choice: { type: 'auto' } }, + { label: 'any', tool_choice: { type: 'any' } }, + { label: 'tool', tool_choice: { type: 'tool', name: 'get_weather' } }, + { label: 'auto + no parallel', tool_choice: { type: 'auto', disable_parallel_tool_use: true } }, +]; + +for (const { label, tool_choice } of choices) { + const message = await client.messages.create({ + model, + max_tokens: 256, + tools, + tool_choice, + messages: [{ role: 'user', content: prompt }], + }); + + const used = message.content.find( + (block): block is Anthropic.ToolUseBlock => block.type === 'tool_use', + ); + const chose = used ? used.name : 'none (answered in text)'; + console.log(`${label.padEnd(18)} stop_reason=${message.stop_reason} chose=${chose}`); +} diff --git a/examples/05-tools/tool-errors.ts b/examples/05-tools/tool-errors.ts new file mode 100644 index 0000000..7fb5a83 --- /dev/null +++ b/examples/05-tools/tool-errors.ts @@ -0,0 +1,68 @@ +// bun run examples/05-tools/tool-errors.ts + +import Anthropic from '@anthropic-ai/sdk'; + +const client = new Anthropic(); +const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6'; + +const tools: Anthropic.Tool[] = [ + { + name: 'divide', + description: 'Divide one number by another. Call this for any division.', + input_schema: { + type: 'object', + properties: { + numerator: { type: 'number', description: 'The number being divided.' }, + denominator: { type: 'number', description: 'The number to divide by.' }, + }, + required: ['numerator', 'denominator'], + }, + }, +]; + +type DivideInput = { numerator: number; denominator: number }; + +// block.input arrives as a parsed object from the SDK - read it, never re-parse the raw JSON. +function divide(block: Anthropic.ToolUseBlock): Anthropic.ToolResultBlockParam { + const { numerator, denominator } = block.input as DivideInput; + + // On bad input, return is_error: true with a message the model can act on - do not throw. + if (denominator === 0) { + return { + type: 'tool_result', + tool_use_id: block.id, + is_error: true, + content: 'denominator was 0; division is undefined. Ask the user for a non-zero divisor.', + }; + } + + // A normal success result echoes the same tool_use_id and omits is_error. + return { + type: 'tool_result', + tool_use_id: block.id, + content: String(numerator / denominator), + }; +} + +const messages: Anthropic.MessageParam[] = [ + { role: 'user', content: 'Use the divide tool: first compute 7 / 0, then 9 / 3.' }, +]; +const message = await client.messages.create({ model, max_tokens: 512, tools, messages }); + +if (message.stop_reason === 'tool_use') { + messages.push({ role: 'assistant', content: message.content }); + + const results = message.content + .filter((block) => block.type === 'tool_use') + .map(divide); + + for (const result of results) { + console.log(`${result.is_error ? 'error' : 'ok'}: ${result.content}`); + } + + messages.push({ role: 'user', content: results }); + const answer = await client.messages.create({ model, max_tokens: 512, tools, messages }); + + const text = answer.content.find((block) => block.type === 'text'); + console.log('claude:', text?.text ?? ''); +}