From 9d5345f15d8fffe17a91859a16e5d7644a511d1a Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Mon, 20 Apr 2026 00:45:26 -0700 Subject: [PATCH] feat(examples): meeting summarizer pattern (#135) (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds examples/patterns/meeting-summarizer.ts — fan-out pattern tailored for meeting transcripts. Three specialists run in parallel on the same transcript: - summary: three-paragraph prose summary - action-items: Zod-validated { task, owner, due_date? }[] schema - sentiment: Zod-validated per-participant tone + evidence Each specialist's wall duration is recorded, and the script asserts parallel wall time < 70% of the sum of per-agent durations to prove fan-out gives a real speedup. An aggregator then merges the three outputs into a single Markdown report with four H2 sections (Summary, Action Items, Sentiment, Next Steps). Ships with examples/fixtures/meeting-transcript.txt — a 350-word engineering standup transcript designed so the three schemas exercise cleanly (concrete owners + due dates for action items, a mixed-tone on-call engineer for sentiment). Lint and test pass on upstream/main. Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- examples/README.md | 1 + examples/fixtures/meeting-transcript.txt | 21 ++ examples/patterns/meeting-summarizer.ts | 284 +++++++++++++++++++++++ 3 files changed, 306 insertions(+) create mode 100644 examples/fixtures/meeting-transcript.txt create mode 100644 examples/patterns/meeting-summarizer.ts diff --git a/examples/README.md b/examples/README.md index 74eabe8..5109f74 100644 --- a/examples/README.md +++ b/examples/README.md @@ -44,6 +44,7 @@ Reusable shapes for common multi-agent problems. | [`patterns/multi-perspective-code-review`](patterns/multi-perspective-code-review.ts) | Multiple reviewer agents in parallel, then synthesis. | | [`patterns/research-aggregation`](patterns/research-aggregation.ts) | Multi-source research collated by a synthesis agent. | | [`patterns/agent-handoff`](patterns/agent-handoff.ts) | Synchronous sub-agent delegation via `delegate_to_agent`. | +| [`patterns/meeting-summarizer`](patterns/meeting-summarizer.ts) | Fan-out post-processing of a transcript into summary, structured action items, and sentiment. | ## integrations — external systems diff --git a/examples/fixtures/meeting-transcript.txt b/examples/fixtures/meeting-transcript.txt new file mode 100644 index 0000000..ef2e628 --- /dev/null +++ b/examples/fixtures/meeting-transcript.txt @@ -0,0 +1,21 @@ +Weekly Engineering Standup — 2026-04-18 +Attendees: Maya (Eng Manager), Raj (Senior Backend), Priya (Frontend Lead), Dan (SRE) + +Maya: Quick round-table. Raj, where are we on the billing-v2 migration? +Raj: Cutover is scheduled for Tuesday the 28th. I want to get the shadow-write harness deployed by Friday so we have a full weekend of production traffic comparisons before the cutover. I'll own that. Concerned about the reconciliation query taking 40 seconds on the biggest accounts; I'll look into adding a covering index before cutover. + +Maya: Good. Priya, the checkout redesign? +Priya: Ship-ready. I finished the accessibility audit yesterday, all high-priority items landed. Two medium items on the backlog I'll tackle next sprint. Planning to flip the feature flag for 5% of traffic on Thursday the 23rd and ramp from there. I've been heads-down on this for three weeks and honestly feeling pretty good about where it landed. + +Maya: Great. Dan, Sunday's incident — what's the status on the retro? +Dan: Retro doc is up. Root cause was the failover script assuming a single-region topology after we moved to multi-region in Q1. The script hasn't been exercised in production since February. I'm frustrated that nobody caught it in review — the change was obvious if you read the diff, but it's twenty pages of YAML. I'm going to propose a rule that multi-region changes need a second reviewer on the SRE team. That's an action for me before the next postmortem, I'll have it drafted by Monday the 27th. + +Maya: Reasonable. Anything else? Dan, how are you holding up? You've been on call a lot. +Dan: Honestly? Tired. The back-to-back incidents took the wind out of me. I'd like to hand off primary next rotation. I'll work with Raj on the handoff doc. + +Maya: Noted. Let's make that happen. Priya, anything blocking you? +Priya: Nope, feeling good. + +Raj: Just flagging — I saw the Slack thread about the authz refactor. If we're doing that this quarter, it conflicts with billing-v2 timelines. Can we park it until May? + +Maya: Yes, I'll follow up with Len and reply in the thread. Thanks everyone. diff --git a/examples/patterns/meeting-summarizer.ts b/examples/patterns/meeting-summarizer.ts new file mode 100644 index 0000000..68ea1b1 --- /dev/null +++ b/examples/patterns/meeting-summarizer.ts @@ -0,0 +1,284 @@ +/** + * Meeting Summarizer (Parallel Post-Processing) + * + * Demonstrates: + * - Fan-out of three specialized agents on the same meeting transcript + * - Structured output (Zod schemas) for action items and sentiment + * - Parallel timing check: wall time vs sum of per-agent durations + * - Aggregator merges into a single Markdown report + * + * Run: + * npx tsx examples/patterns/meeting-summarizer.ts + * + * Prerequisites: + * ANTHROPIC_API_KEY env var must be set. + */ + +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import { z } from 'zod' +import { Agent, AgentPool, ToolRegistry, ToolExecutor, registerBuiltInTools } from '../../src/index.js' +import type { AgentConfig, AgentRunResult } from '../../src/types.js' + +// --------------------------------------------------------------------------- +// Load the transcript fixture +// --------------------------------------------------------------------------- + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const TRANSCRIPT = readFileSync( + path.join(__dirname, '../fixtures/meeting-transcript.txt'), + 'utf-8', +) + +// --------------------------------------------------------------------------- +// Zod schemas for structured agents +// --------------------------------------------------------------------------- + +const ActionItemList = z.object({ + items: z.array( + z.object({ + task: z.string().describe('The action to be taken'), + owner: z.string().describe('Name of the person responsible'), + due_date: z.string().optional().describe('ISO date or human-readable due date if mentioned'), + }), + ), +}) +type ActionItemList = z.infer + +const SentimentReport = z.object({ + participants: z.array( + z.object({ + participant: z.string().describe('Name as it appears in the transcript'), + tone: z.enum(['positive', 'neutral', 'negative', 'mixed']), + evidence: z.string().describe('Direct quote or brief paraphrase supporting the tone'), + }), + ), +}) +type SentimentReport = z.infer + +// --------------------------------------------------------------------------- +// Agent configs +// --------------------------------------------------------------------------- + +const summaryConfig: AgentConfig = { + name: 'summary', + model: 'claude-sonnet-4-6', + systemPrompt: `You are a meeting note-taker. Given a transcript, produce a +three-paragraph summary: + +1. What was discussed (the agenda). +2. Decisions made. +3. Notable context or risk the team should remember. + +Plain prose. No bullet points. 200-300 words total.`, + maxTurns: 1, + temperature: 0.3, +} + +const actionItemsConfig: AgentConfig = { + name: 'action-items', + model: 'claude-sonnet-4-6', + systemPrompt: `You extract action items from meeting transcripts. An action +item is a concrete task with a clear owner. Skip vague intentions ("we should +think about X"). Include due dates only when the speaker named one explicitly. + +Return JSON matching the schema.`, + maxTurns: 1, + temperature: 0.1, + outputSchema: ActionItemList, +} + +const sentimentConfig: AgentConfig = { + name: 'sentiment', + model: 'claude-sonnet-4-6', + systemPrompt: `You analyze the tone of each participant in a meeting. For +every named speaker, classify their overall tone as positive, neutral, +negative, or mixed, and include one short quote or paraphrase as evidence. + +Return JSON matching the schema.`, + maxTurns: 1, + temperature: 0.2, + outputSchema: SentimentReport, +} + +const aggregatorConfig: AgentConfig = { + name: 'aggregator', + model: 'claude-sonnet-4-6', + systemPrompt: `You are a report writer. You receive three pre-computed +analyses of the same meeting: a summary, an action-item list, and a sentiment +report. Your job is to merge them into a single Markdown report. + +Output structure — use exactly these four H2 headings, in order: + +## Summary +## Action Items +## Sentiment +## Next Steps + +Under "Action Items" render a Markdown table with columns: Task, Owner, Due. +Under "Sentiment" render one bullet per participant. +Under "Next Steps" synthesize 3-5 concrete follow-ups based on the other +sections. Do not invent action items that are not grounded in the other data.`, + maxTurns: 1, + temperature: 0.3, +} + +// --------------------------------------------------------------------------- +// Build agents +// --------------------------------------------------------------------------- + +function buildAgent(config: AgentConfig): Agent { + const registry = new ToolRegistry() + registerBuiltInTools(registry) + const executor = new ToolExecutor(registry) + return new Agent(config, registry, executor) +} + +const summary = buildAgent(summaryConfig) +const actionItems = buildAgent(actionItemsConfig) +const sentiment = buildAgent(sentimentConfig) +const aggregator = buildAgent(aggregatorConfig) + +const pool = new AgentPool(3) // three specialists can run concurrently +pool.add(summary) +pool.add(actionItems) +pool.add(sentiment) +pool.add(aggregator) + +console.log('Meeting Summarizer (Parallel Post-Processing)') +console.log('='.repeat(60)) +console.log(`\nTranscript: ${TRANSCRIPT.split('\n')[0]}`) +console.log(`Length: ${TRANSCRIPT.split(/\s+/).length} words\n`) + +// --------------------------------------------------------------------------- +// Step 1: Parallel fan-out with per-agent timing +// --------------------------------------------------------------------------- + +console.log('[Step 1] Running 3 agents in parallel...\n') + +const specialists = ['summary', 'action-items', 'sentiment'] as const + +// Kick off all three concurrently and record each one's own wall duration. +// Sum-of-per-agent beats a separate serial pass: half the LLM cost, and the +// sum is the work parallelism saved. +const parallelStart = performance.now() +const timed = await Promise.all( + specialists.map(async (name) => { + const t = performance.now() + const result = await pool.run(name, TRANSCRIPT) + return { name, result, durationMs: performance.now() - t } + }), +) +const parallelElapsed = performance.now() - parallelStart + +const byName = new Map() +const serialSum = timed.reduce((acc, r) => { + byName.set(r.name, r.result) + return acc + r.durationMs +}, 0) + +for (const { name, result, durationMs } of timed) { + const status = result.success ? 'OK' : 'FAILED' + console.log( + ` ${name.padEnd(14)} [${status}] — ${Math.round(durationMs)}ms, ${result.tokenUsage.output_tokens} out tokens`, + ) +} +console.log() + +for (const { name, result } of timed) { + if (!result.success) { + console.error(`Specialist '${name}' failed: ${result.output}`) + process.exit(1) + } +} + +const actionData = byName.get('action-items')!.structured as ActionItemList | undefined +const sentimentData = byName.get('sentiment')!.structured as SentimentReport | undefined + +if (!actionData || !sentimentData) { + console.error('Structured output missing: action-items or sentiment failed schema validation') + process.exit(1) +} + +// --------------------------------------------------------------------------- +// Step 2: Parallelism assertion +// --------------------------------------------------------------------------- + +console.log('[Step 2] Parallelism check') +console.log(` Parallel wall time: ${Math.round(parallelElapsed)}ms`) +console.log(` Serial sum (per-agent): ${Math.round(serialSum)}ms`) +console.log(` Speedup: ${(serialSum / parallelElapsed).toFixed(2)}x\n`) + +if (parallelElapsed >= serialSum * 0.7) { + console.error( + `ASSERTION FAILED: parallel wall time (${Math.round(parallelElapsed)}ms) is not ` + + `less than 70% of serial sum (${Math.round(serialSum)}ms). Expected substantial ` + + `speedup from fan-out.`, + ) + process.exit(1) +} + +// --------------------------------------------------------------------------- +// Step 3: Aggregate into Markdown report +// --------------------------------------------------------------------------- + +console.log('[Step 3] Aggregating into Markdown report...\n') + +const aggregatorPrompt = `Merge the three analyses below into a single Markdown report. + +--- SUMMARY (prose) --- +${byName.get('summary')!.output} + +--- ACTION ITEMS (JSON) --- +${JSON.stringify(actionData, null, 2)} + +--- SENTIMENT (JSON) --- +${JSON.stringify(sentimentData, null, 2)} + +Produce the Markdown report per the system instructions.` + +const reportResult = await pool.run('aggregator', aggregatorPrompt) + +if (!reportResult.success) { + console.error('Aggregator failed:', reportResult.output) + process.exit(1) +} + +// --------------------------------------------------------------------------- +// Final output +// --------------------------------------------------------------------------- + +console.log('='.repeat(60)) +console.log('MEETING REPORT') +console.log('='.repeat(60)) +console.log() +console.log(reportResult.output) +console.log() +console.log('-'.repeat(60)) + +// --------------------------------------------------------------------------- +// Token usage summary +// --------------------------------------------------------------------------- + +console.log('\nToken Usage Summary:') +console.log('-'.repeat(60)) + +let totalInput = 0 +let totalOutput = 0 +for (const { name, result } of timed) { + totalInput += result.tokenUsage.input_tokens + totalOutput += result.tokenUsage.output_tokens + console.log( + ` ${name.padEnd(14)} — input: ${result.tokenUsage.input_tokens}, output: ${result.tokenUsage.output_tokens}`, + ) +} +totalInput += reportResult.tokenUsage.input_tokens +totalOutput += reportResult.tokenUsage.output_tokens +console.log( + ` ${'aggregator'.padEnd(14)} — input: ${reportResult.tokenUsage.input_tokens}, output: ${reportResult.tokenUsage.output_tokens}`, +) +console.log('-'.repeat(60)) +console.log(` ${'TOTAL'.padEnd(14)} — input: ${totalInput}, output: ${totalOutput}`) + +console.log('\nDone.')