From 3d4c2e9d6a584aff5a81302d5be09840c575dbd3 Mon Sep 17 00:00:00 2001 From: yagop Date: Tue, 16 Jun 2026 23:18:00 +0000 Subject: [PATCH] Implement #6: Building Tool Chains and Complex Workflows Chapter 6 plus its examples/06-tool-chains/ samples: the hand-written agent loop (branch on stop_reason, run until end_turn), sequential chaining, parallel tool_use via Promise.all, tool_choice and the forcing/termination gotcha, and a reusable Map-based runner with a maxIterations cap and is_error recovery - also wired to stream + finalMessage. Wires the chapter into the sidebar and README. Co-Authored-By: Claude Opus 4.8 --- .vitepress/config.ts | 4 ++ README.md | 1 + chapters/06-tool-chains.md | 65 +++++++++++++++++ examples/06-tool-chains/agent-loop.ts | 45 ++++++++++++ examples/06-tool-chains/agent-runner.ts | 69 +++++++++++++++++++ examples/06-tool-chains/parallel-tools.ts | 44 ++++++++++++ examples/06-tool-chains/runner-with-stream.ts | 50 ++++++++++++++ examples/06-tool-chains/sequential-chain.ts | 62 +++++++++++++++++ examples/06-tool-chains/tool-choice.ts | 38 ++++++++++ 9 files changed, 378 insertions(+) create mode 100644 chapters/06-tool-chains.md create mode 100644 examples/06-tool-chains/agent-loop.ts create mode 100644 examples/06-tool-chains/agent-runner.ts create mode 100644 examples/06-tool-chains/parallel-tools.ts create mode 100644 examples/06-tool-chains/runner-with-stream.ts create mode 100644 examples/06-tool-chains/sequential-chain.ts create mode 100644 examples/06-tool-chains/tool-choice.ts diff --git a/.vitepress/config.ts b/.vitepress/config.ts index 16411c9..f2c09e0 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -68,6 +68,10 @@ export default { text: '5. Implementing Tools and Function Calling', link: '/chapters/05-tools', }, + { + text: '6. Building Tool Chains and Complex Workflows', + link: '/chapters/06-tool-chains', + }, ], }, ], diff --git a/README.md b/README.md index 843d8c9..5f04fd8 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Published with VitePress at https://yagop.github.io/coding-agents-tutorial/. Eac 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) +6. [Building Tool Chains and Complex Workflows](chapters/06-tool-chains.md) More chapters are tracked as issues and land here as they are written. diff --git a/chapters/06-tool-chains.md b/chapters/06-tool-chains.md new file mode 100644 index 0000000..eade247 --- /dev/null +++ b/chapters/06-tool-chains.md @@ -0,0 +1,65 @@ +# Building Tool Chains and Complex Workflows + +🔁 In Chapter 5 you ran a single tool round-trip: one call, one result, one answer. Real work rarely fits in one exchange - the model looks something up, reads the result, decides it needs one more thing, and only then writes its reply. This chapter turns that single round-trip into an *engine*: a loop that keeps calling `client.messages.create` until the model is finally done. Get this loop right and everything else - sequential chains, parallel calls, a reusable runner - is just a variation on it. + +You already have tools, `tool_use`/`tool_result`, and content-block narrowing from Chapter 5, so here you only add the loop around them. As always the key comes from the environment - never hardcode it - and Bun auto-loads `.env`. + +## The agent loop + +The engine is smaller than you expect: call the model, then branch on `stop_reason`. If it is `end_turn`, the model is done - print the text and stop. If it is `tool_use`, run the requested tools, feed the results back, and call again. The one rule a beginner cannot guess from the types: append the model's entire `response.content` as the assistant turn *before* you add any `tool_result`, so every `tool_use` block is answered exactly once. + +<<< @/examples/06-tool-chains/agent-loop.ts + +Run it and watch the loop turn over more than once - the model asks about Paris and Oslo, reads both, then answers: + +```sh +bun run examples/06-tool-chains/agent-loop.ts +``` + +The loop exits on `stop_reason !== 'tool_use'`; `end_turn` is the everyday case, and you will meet `max_tokens` and `pause_turn` when a later chapter needs them. + +## Chaining and parallel calls + +Two shapes of multi-tool work fall out of the same loop. A **sequential chain** is when one tool's output is the next tool's input - look up an id, then fetch something for that id. You do not orchestrate this yourself: the model calls the first tool, reads the result on the next turn, and only then calls the second. + +<<< @/examples/06-tool-chains/sequential-chain.ts + +The model serializes the dependent calls on its own - `find_user` returns `u_42`, and only the following turn calls `get_balance` with it. + +When calls are *independent*, the model often asks for them all in one turn - several `tool_use` blocks at once. Filter them with `b.type === 'tool_use'` and resolve them together with `Promise.all`, returning one `tool_result` per block. + +<<< @/examples/06-tool-chains/parallel-tools.ts + +Independent results all ride back in a single `user` turn, so the model gets the whole batch at once. The rule of thumb: return what you can in parallel, and let the model serialize whatever genuinely depends on an earlier answer. + +## Steering the loop with tool_choice + +`tool_choice` decides whether - and which - tool the model must call, and it touches your loop in one sharp way. With `auto` the model picks; with `none` it may not call a tool at all; with `any` or `{ type: 'tool', name }` it is *forced* to call one. + +<<< @/examples/06-tool-chains/tool-choice.ts + +The gotcha worth a warning: a forced tool call comes back as `stop_reason: 'tool_use'`, never `end_turn` on that turn. + +::: warning Forcing and termination +Because `{ type: 'any' }` and `{ type: 'tool', name }` guarantee a `tool_use` turn, the model can never say "I'm done" while a tool is forced. Keep the loop's exit condition on `end_turn`, and only force on the turns where you actually want a call - force every iteration and the loop runs forever. +::: + +## A reusable runner + +Everything so far has been one-off scripts. Lift the loop into a function and it becomes a runner you can drop in front of any input: pass the `messages`, the `Anthropic.Tool[]`, and a `Map` from tool name to handler, plus a `maxIterations` cap so a misbehaving model can never spin forever, and `is_error: true` whenever a handler is missing or throws. + +<<< @/examples/06-tool-chains/agent-runner.ts + +The handler `Map` is the only thing that grows per app; the loop, the cap, and the error handling stay put. This is the same runner you would wire to the Telegram bot from Chapter 3. + +Because the body is just the loop, you can swap `create` for `stream` plus `finalMessage()` to surface the model's text as it arrives - the tool round-trips happen quietly in between. + +<<< @/examples/06-tool-chains/runner-with-stream.ts + +`finalMessage()` hands back the same assembled `Message` - `stop_reason`, `content`, and all - so the loop logic is unchanged; only the output went live. + +::: details Going deeper: betaZodTool and toolRunner +Once you have written the loop by hand, the SDK's `client.beta.messages.toolRunner` is a thin wrapper over exactly this shape - it runs the create / tool / result cycle for you. Pair it with `betaZodTool` (from `@anthropic-ai/sdk/helpers/beta/zod`) to define a tool from a Zod schema and receive a typed `input` instead of casting. Reach for it once the hand-written loop feels obvious; until then, the loop above is the version whose behavior you can actually see. +::: + +What's next: Chapter 7 - Advanced Agent Patterns. diff --git a/examples/06-tool-chains/agent-loop.ts b/examples/06-tool-chains/agent-loop.ts new file mode 100644 index 0000000..eb19bb0 --- /dev/null +++ b/examples/06-tool-chains/agent-loop.ts @@ -0,0 +1,45 @@ +// bun run examples/06-tool-chains/agent-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: 'Get the current weather for a city. Call this when asked about weather.', + input_schema: { + type: 'object', + properties: { city: { type: 'string', description: 'City name, e.g. Paris' } }, + required: ['city'], + }, + }, +]; + +function runTool(block: Anthropic.ToolUseBlock): string { + const { city } = block.input as { city: string }; + return `It is 18C and clear in ${city}.`; +} + +const messages: Anthropic.MessageParam[] = [ + { role: 'user', content: 'What is the weather in Paris, and is it warmer than Oslo?' }, +]; + +while (true) { + const response = await client.messages.create({ model, max_tokens: 1024, tools, messages }); + messages.push({ role: 'assistant', content: response.content }); + + if (response.stop_reason !== 'tool_use') { + const text = response.content.find((b) => b.type === 'text'); + console.log(text?.text ?? ''); + break; + } + + const results: Anthropic.ToolResultBlockParam[] = []; + for (const block of response.content) { + if (block.type !== 'tool_use') continue; + results.push({ type: 'tool_result', tool_use_id: block.id, content: runTool(block) }); + } + messages.push({ role: 'user', content: results }); +} diff --git a/examples/06-tool-chains/agent-runner.ts b/examples/06-tool-chains/agent-runner.ts new file mode 100644 index 0000000..5dc197c --- /dev/null +++ b/examples/06-tool-chains/agent-runner.ts @@ -0,0 +1,69 @@ +// bun run examples/06-tool-chains/agent-runner.ts + +import Anthropic from '@anthropic-ai/sdk'; + +const client = new Anthropic(); +const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6'; + +type Handler = (input: Anthropic.ToolUseBlock['input']) => Promise | string; + +async function runAgent( + messages: Anthropic.MessageParam[], + tools: Anthropic.Tool[], + handlers: Map, + maxIterations = 10, +): Promise { + for (let i = 0; i < maxIterations; i++) { + const response = await client.messages.create({ model, max_tokens: 1024, tools, messages }); + messages.push({ role: 'assistant', content: response.content }); + + if (response.stop_reason !== 'tool_use') { + const text = response.content.find((b) => b.type === 'text'); + return text?.text ?? ''; + } + + const calls = response.content.filter((b) => b.type === 'tool_use'); + const results = await Promise.all( + calls.map(async (block): Promise => { + const handler = handlers.get(block.name); + if (!handler) { + return { type: 'tool_result', tool_use_id: block.id, content: `unknown tool ${block.name}`, is_error: true }; + } + try { + return { type: 'tool_result', tool_use_id: block.id, content: await handler(block.input) }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + return { type: 'tool_result', tool_use_id: block.id, content: reason, is_error: true }; + } + }), + ); + messages.push({ role: 'user', content: results }); + } + return `stopped after ${maxIterations} iterations`; +} + +const tools: Anthropic.Tool[] = [ + { + name: 'add', + description: 'Add two numbers and return the sum.', + input_schema: { + type: 'object', + properties: { a: { type: 'number', description: 'First addend' }, b: { type: 'number', description: 'Second addend' } }, + required: ['a', 'b'], + }, + }, +]; + +const handlers = new Map([ + ['add', (input) => { + const { a, b } = input as { a: number; b: number }; + return String(a + b); + }], +]); + +const answer = await runAgent( + [{ role: 'user', content: 'What is 19 + 23, and then add 100 to that result?' }], + tools, + handlers, +); +console.log(answer); diff --git a/examples/06-tool-chains/parallel-tools.ts b/examples/06-tool-chains/parallel-tools.ts new file mode 100644 index 0000000..2345afe --- /dev/null +++ b/examples/06-tool-chains/parallel-tools.ts @@ -0,0 +1,44 @@ +// bun run examples/06-tool-chains/parallel-tools.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: 'Get the current weather for one city. Call once per city.', + input_schema: { + type: 'object', + properties: { city: { type: 'string', description: 'City name' } }, + required: ['city'], + }, + }, +]; + +async function runTool(block: Anthropic.ToolUseBlock): Promise { + const { city } = block.input as { city: string }; + await new Promise((resolve) => setTimeout(resolve, 100)); + return { type: 'tool_result', tool_use_id: block.id, content: `20C in ${city}` }; +} + +const messages: Anthropic.MessageParam[] = [ + { role: 'user', content: 'Compare the weather in Paris, Tokyo, and Cairo right now.' }, +]; + +while (true) { + const response = await client.messages.create({ model, max_tokens: 1024, tools, messages }); + messages.push({ role: 'assistant', content: response.content }); + + if (response.stop_reason !== 'tool_use') { + const text = response.content.find((b) => b.type === 'text'); + console.log(text?.text ?? ''); + break; + } + + const calls = response.content.filter((b) => b.type === 'tool_use'); + console.log(`running ${calls.length} tool call(s) for this turn`); + const results = await Promise.all(calls.map(runTool)); + messages.push({ role: 'user', content: results }); +} diff --git a/examples/06-tool-chains/runner-with-stream.ts b/examples/06-tool-chains/runner-with-stream.ts new file mode 100644 index 0000000..e078743 --- /dev/null +++ b/examples/06-tool-chains/runner-with-stream.ts @@ -0,0 +1,50 @@ +// bun run examples/06-tool-chains/runner-with-stream.ts + +import Anthropic from '@anthropic-ai/sdk'; + +const client = new Anthropic(); +const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6'; + +type Handler = (input: Anthropic.ToolUseBlock['input']) => string; + +const tools: Anthropic.Tool[] = [ + { + name: 'roll_die', + description: 'Roll a die with the given number of sides and return the result.', + input_schema: { + type: 'object', + properties: { sides: { type: 'number', description: 'Number of sides' } }, + required: ['sides'], + }, + }, +]; + +const handlers = new Map([ + ['roll_die', (input) => { + const { sides } = input as { sides: number }; + return String(1 + Math.floor(Math.random() * sides)); + }], +]); + +const messages: Anthropic.MessageParam[] = [ + { role: 'user', content: 'Roll a 20-sided die, then tell me whether it beat a 10.' }, +]; + +for (let i = 0; i < 10; i++) { + const stream = client.messages.stream({ model, max_tokens: 1024, tools, messages }); + stream.on('text', (delta) => process.stdout.write(delta)); + const response = await stream.finalMessage(); + messages.push({ role: 'assistant', content: response.content }); + + if (response.stop_reason !== 'tool_use') break; + + const results: Anthropic.ToolResultBlockParam[] = []; + for (const block of response.content) { + if (block.type !== 'tool_use') continue; + const handler = handlers.get(block.name); + const content = handler ? handler(block.input) : `unknown tool ${block.name}`; + results.push({ type: 'tool_result', tool_use_id: block.id, content, is_error: !handler }); + } + messages.push({ role: 'user', content: results }); +} +process.stdout.write('\n'); diff --git a/examples/06-tool-chains/sequential-chain.ts b/examples/06-tool-chains/sequential-chain.ts new file mode 100644 index 0000000..b25cfb4 --- /dev/null +++ b/examples/06-tool-chains/sequential-chain.ts @@ -0,0 +1,62 @@ +// bun run examples/06-tool-chains/sequential-chain.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: 'find_user', + description: 'Look up a user id by name. Call this first when you only have a name.', + input_schema: { + type: 'object', + properties: { name: { type: 'string', description: 'Full name' } }, + required: ['name'], + }, + }, + { + name: 'get_balance', + description: 'Get the account balance for a user id. Needs the id from find_user.', + input_schema: { + type: 'object', + properties: { userId: { type: 'string', description: 'User id like u_42' } }, + required: ['userId'], + }, + }, +]; + +function runTool(block: Anthropic.ToolUseBlock): string { + if (block.name === 'find_user') { + const { name } = block.input as { name: string }; + return name.includes('Ada') ? 'u_42' : 'u_unknown'; + } + const { userId } = block.input as { userId: string }; + return userId === 'u_42' ? '1200.50 USD' : 'no such account'; +} + +const messages: Anthropic.MessageParam[] = [ + { role: 'user', content: "What is Ada Lovelace's account balance?" }, +]; + +while (true) { + const response = await client.messages.create({ model, max_tokens: 1024, tools, messages }); + messages.push({ role: 'assistant', content: response.content }); + + for (const block of response.content) { + if (block.type === 'tool_use') console.log(`-> ${block.name}(${JSON.stringify(block.input)})`); + } + + if (response.stop_reason !== 'tool_use') { + const text = response.content.find((b) => b.type === 'text'); + console.log(text?.text ?? ''); + break; + } + + const results: Anthropic.ToolResultBlockParam[] = []; + for (const block of response.content) { + if (block.type !== 'tool_use') continue; + results.push({ type: 'tool_result', tool_use_id: block.id, content: runTool(block) }); + } + messages.push({ role: 'user', content: results }); +} diff --git a/examples/06-tool-chains/tool-choice.ts b/examples/06-tool-chains/tool-choice.ts new file mode 100644 index 0000000..5dc0499 --- /dev/null +++ b/examples/06-tool-chains/tool-choice.ts @@ -0,0 +1,38 @@ +// bun run examples/06-tool-chains/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_time', + description: 'Get the current time in a city.', + input_schema: { + type: 'object', + properties: { city: { type: 'string', description: 'City name' } }, + required: ['city'], + }, + }, +]; + +const prompt = 'Just say hello - do not look anything up.'; +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_time' } }, + { label: 'none', tool_choice: { type: 'none' } }, +]; + +for (const { label, tool_choice } of choices) { + const response = await client.messages.create({ + model, + max_tokens: 256, + tools, + tool_choice, + messages: [{ role: 'user', content: prompt }], + }); + const calledTool = response.content.some((b) => b.type === 'tool_use'); + console.log(`${label.padEnd(5)} stop_reason=${response.stop_reason} called_tool=${calledTool}`); +}