diff --git a/.vitepress/config.ts b/.vitepress/config.ts index f2c09e0..5429c9e 100644 --- a/.vitepress/config.ts +++ b/.vitepress/config.ts @@ -72,6 +72,10 @@ export default { text: '6. Building Tool Chains and Complex Workflows', link: '/chapters/06-tool-chains', }, + { + text: '7. Advanced Agent Patterns', + link: '/chapters/07-advanced-patterns', + }, ], }, ], diff --git a/README.md b/README.md index 5f04fd8..166ca47 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Published with VitePress at https://yagop.github.io/coding-agents-tutorial/. Eac 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) +7. [Advanced Agent Patterns](chapters/07-advanced-patterns.md) More chapters are tracked as issues and land here as they are written. diff --git a/chapters/07-advanced-patterns.md b/chapters/07-advanced-patterns.md new file mode 100644 index 0000000..9c57dc5 --- /dev/null +++ b/chapters/07-advanced-patterns.md @@ -0,0 +1,69 @@ +# Advanced Agent Patterns + +🧠 Chapters 5 and 6 gave you a loop that calls tools until the model is done. That loop is the skeleton; this chapter adds the muscles - a step budget so it can never run away, extended thinking so it reasons before it acts, self-critique so it catches its own mistakes, structured output you can parse, error recovery, and a coordinator that hands work to helper agents. Each pattern is a small, independent addition to the loop you already know. + +You already have the tool round-trip and the agent loop from Chapters 5 and 6, so here you only add patterns on top. As always the key comes from the environment - never hardcode it - and Bun auto-loads `.env`. + +## The autonomous loop + +An autonomous agent is the loop with a leash. You accumulate `messages`, branch on `stop_reason`, and - the part beginners skip - cap the number of steps so a confused model can never spin forever. Track `usage` as you go, so you can see what each run costs. + +<<< @/examples/07-advanced-patterns/autonomous-loop.ts + +Run it and watch the step count and running token total print each iteration: + +```sh +bun run examples/07-advanced-patterns/autonomous-loop.ts +``` + +The loop exits two ways: cleanly on `end_turn`, or defensively when `maxSteps` is hit - never trusting the model to stop on its own. + +::: details Going deeper: token guardrails +That running `totalTokens` is the hook for a hard budget. Read `usage.input_tokens` and `usage.output_tokens` after each step and `break` once a threshold is crossed - a runaway agent costs money one step at a time, and inside the loop is the only place to catch it. +::: + +## Thinking and reflecting + +Sometimes you want the model to reason before it answers. Extended thinking turns that reasoning into its own `thinking` content block you can read, separate from the final text - and those thinking tokens count toward `usage`. + +<<< @/examples/07-advanced-patterns/extended-thinking.ts + +You enable it with `thinking: { type: 'adaptive' }`, then narrow on `block.type === 'thinking'` to read `block.thinking`. + +::: info Older models +`adaptive` is the current shape. On models 4.6 and earlier, use `thinking: { type: 'enabled', budget_tokens: N }` instead (at least 1024, and below `max_tokens`). +::: + +A cheaper kind of reasoning is to let the model grade its own work. After it writes a draft, you ask it to critique and revise - but the critique must go back as a plain `user` turn, never as a fabricated `tool_result`. + +<<< @/examples/07-advanced-patterns/reflection.ts + +The draft is appended verbatim as the assistant turn, and the critique request is an ordinary `user` message - so the model treats it as feedback to act on, not as a tool's output. + +## Structured output and recovering from errors + +When you need machine-readable output instead of prose, give the model one tool and force it with `tool_choice` - it must call the tool, so its `input` becomes your typed result. + +<<< @/examples/07-advanced-patterns/structured-output.ts + +The `enum` and `required` fields pin the shape; you read `block.input` as your own type and skip parsing free text entirely. + +Real agents also hit failures - a rate limit, a tool that throws. Retry transient API errors with backoff, and turn tool failures into `tool_result` blocks with `is_error: true` so the model can adapt instead of crashing. + +<<< @/examples/07-advanced-patterns/error-recovery.ts + +`Anthropic.RateLimitError` gets exponential backoff; a thrown tool becomes an error result the model reads and works around - here it reports the missing file instead of stalling. + +## Orchestrating subagents + +For bigger jobs, one agent becomes a coordinator: it holds the high-level tools, and each tool call is dispatched to a helper that runs its own `client.messages.create` with a narrow job and its own context. + +<<< @/examples/07-advanced-patterns/orchestrator.ts + +The coordinator never sees the subagent's internal turns - only its final result comes back as a `tool_result`, which keeps each context small and focused. + +::: details Going deeper: a planning tool +A common first move for a coordinator is a dedicated `make_plan` tool that returns an explicit task list. The loop then iterates those subtasks on following turns, dispatching each to a helper - so planning and doing become separate, inspectable steps. +::: + +What's next: Chapter 8 - Production and Deployment. diff --git a/examples/07-advanced-patterns/autonomous-loop.ts b/examples/07-advanced-patterns/autonomous-loop.ts new file mode 100644 index 0000000..9f7051e --- /dev/null +++ b/examples/07-advanced-patterns/autonomous-loop.ts @@ -0,0 +1,46 @@ +// bun run examples/07-advanced-patterns/autonomous-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: 'word_length', + description: 'Return the number of letters in a single word.', + input_schema: { + type: 'object', + properties: { word: { type: 'string', description: 'One word' } }, + required: ['word'], + }, + }, +]; + +const messages: Anthropic.MessageParam[] = [ + { role: 'user', content: 'Use the tool to compare the lengths of "agent" and "orchestration", then say which is longer.' }, +]; + +const maxSteps = 6; +let totalTokens = 0; + +for (let step = 0; step < maxSteps; step++) { + const response = await client.messages.create({ model, max_tokens: 1024, tools, messages }); + totalTokens += response.usage.input_tokens + response.usage.output_tokens; + messages.push({ role: 'assistant', content: response.content }); + console.log(`step ${step + 1}: stop_reason=${response.stop_reason} total_tokens=${totalTokens}`); + + 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; + const { word } = block.input as { word: string }; + results.push({ type: 'tool_result', tool_use_id: block.id, content: String(word.length) }); + } + messages.push({ role: 'user', content: results }); +} diff --git a/examples/07-advanced-patterns/error-recovery.ts b/examples/07-advanced-patterns/error-recovery.ts new file mode 100644 index 0000000..9400f0b --- /dev/null +++ b/examples/07-advanced-patterns/error-recovery.ts @@ -0,0 +1,66 @@ +// bun run examples/07-advanced-patterns/error-recovery.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: 'read_file', + description: 'Read a text file by name.', + input_schema: { + type: 'object', + properties: { path: { type: 'string', description: 'File name' } }, + required: ['path'], + }, + }, +]; + +function readFile(path: string): string { + if (path !== 'notes.txt') throw new Error(`no such file: ${path}`); + return 'remember to water the plants'; +} + +async function createWithBackoff(messages: Anthropic.MessageParam[]): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + try { + return await client.messages.create({ model, max_tokens: 1024, tools, messages }); + } catch (error) { + if (error instanceof Anthropic.RateLimitError && attempt < 2) { + await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000)); + continue; + } + throw error; + } + } + throw new Error('unreachable'); +} + +const messages: Anthropic.MessageParam[] = [ + { role: 'user', content: 'Read notes.txt and config.txt, then tell me what each says or whether it is missing.' }, +]; + +while (true) { + const response = await createWithBackoff(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; + const { path } = block.input as { path: string }; + try { + results.push({ type: 'tool_result', tool_use_id: block.id, content: readFile(path) }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + results.push({ type: 'tool_result', tool_use_id: block.id, content: reason, is_error: true }); + } + } + messages.push({ role: 'user', content: results }); +} diff --git a/examples/07-advanced-patterns/extended-thinking.ts b/examples/07-advanced-patterns/extended-thinking.ts new file mode 100644 index 0000000..11871ab --- /dev/null +++ b/examples/07-advanced-patterns/extended-thinking.ts @@ -0,0 +1,26 @@ +// bun run examples/07-advanced-patterns/extended-thinking.ts + +import Anthropic from '@anthropic-ai/sdk'; + +const client = new Anthropic(); +const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6'; + +const response = await client.messages.create({ + model, + max_tokens: 2048, + thinking: { type: 'adaptive' }, + messages: [ + { role: 'user', content: 'A bat and a ball cost 1.10 in total. The bat costs 1.00 more than the ball. How much is the ball?' }, + ], +}); + +console.log('stop_reason:', response.stop_reason); + +for (const block of response.content) { + if (block.type === 'thinking') { + console.log('\n[thinking]\n' + block.thinking); + } + if (block.type === 'text') { + console.log('\n[answer]\n' + block.text); + } +} diff --git a/examples/07-advanced-patterns/orchestrator.ts b/examples/07-advanced-patterns/orchestrator.ts new file mode 100644 index 0000000..337b5cb --- /dev/null +++ b/examples/07-advanced-patterns/orchestrator.ts @@ -0,0 +1,52 @@ +// bun run examples/07-advanced-patterns/orchestrator.ts + +import Anthropic from '@anthropic-ai/sdk'; + +const client = new Anthropic(); +const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6'; + +async function summarize(text: string): Promise { + const response = await client.messages.create({ + model, + max_tokens: 256, + system: 'You summarize text in one short sentence.', + messages: [{ role: 'user', content: text }], + }); + const block = response.content.find((b) => b.type === 'text'); + return block?.text ?? ''; +} + +const tools: Anthropic.Tool[] = [ + { + name: 'summarize_text', + description: 'Summarize a block of text into one sentence.', + input_schema: { + type: 'object', + properties: { text: { type: 'string', description: 'Text to summarize' } }, + required: ['text'], + }, + }, +]; + +const messages: Anthropic.MessageParam[] = [ + { role: 'user', content: 'Summarize this for me: "The agent loop calls the model, runs any tools it asks for, and repeats until the model is done."' }, +]; + +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; + const { text } = block.input as { text: string }; + results.push({ type: 'tool_result', tool_use_id: block.id, content: await summarize(text) }); + } + messages.push({ role: 'user', content: results }); +} diff --git a/examples/07-advanced-patterns/reflection.ts b/examples/07-advanced-patterns/reflection.ts new file mode 100644 index 0000000..398f77f --- /dev/null +++ b/examples/07-advanced-patterns/reflection.ts @@ -0,0 +1,24 @@ +// bun run examples/07-advanced-patterns/reflection.ts + +import Anthropic from '@anthropic-ai/sdk'; + +const client = new Anthropic(); +const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6'; + +const messages: Anthropic.MessageParam[] = [ + { role: 'user', content: 'Write a one-sentence tagline for a tool that turns shell commands into plain English.' }, +]; + +const draft = await client.messages.create({ model, max_tokens: 512, messages }); +const draftText = draft.content.find((b) => b.type === 'text'); +console.log('draft:', draftText?.text ?? ''); + +messages.push({ role: 'assistant', content: draft.content }); +messages.push({ + role: 'user', + content: 'Critique your tagline for clarity and length, then write one improved version.', +}); + +const revised = await client.messages.create({ model, max_tokens: 512, messages }); +const revisedText = revised.content.find((b) => b.type === 'text'); +console.log('\nrevised:', revisedText?.text ?? ''); diff --git a/examples/07-advanced-patterns/structured-output.ts b/examples/07-advanced-patterns/structured-output.ts new file mode 100644 index 0000000..a41f56c --- /dev/null +++ b/examples/07-advanced-patterns/structured-output.ts @@ -0,0 +1,38 @@ +// bun run examples/07-advanced-patterns/structured-output.ts + +import Anthropic from '@anthropic-ai/sdk'; + +const client = new Anthropic(); +const model = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? 'claude-sonnet-4-6'; + +type Sentiment = { label: 'positive' | 'negative' | 'neutral'; confidence: number }; + +const emitResult: Anthropic.Tool = { + name: 'emit_result', + description: 'Return the sentiment classification as structured data.', + input_schema: { + type: 'object', + properties: { + label: { type: 'string', enum: ['positive', 'negative', 'neutral'] }, + confidence: { type: 'number', description: 'A value from 0 to 1' }, + }, + required: ['label', 'confidence'], + }, +}; + +const response = await client.messages.create({ + model, + max_tokens: 512, + tools: [emitResult], + tool_choice: { type: 'tool', name: 'emit_result' }, + messages: [ + { role: 'user', content: 'Classify the sentiment of: "This refactor saved me hours, fantastic work."' }, + ], +}); + +const block = response.content.find((b) => b.type === 'tool_use'); +if (block && block.type === 'tool_use') { + const result = block.input as Sentiment; + console.log('label:', result.label); + console.log('confidence:', result.confidence); +}