Lesson 10 A Multi-Agent Crew
Theme:

Lab 10 — A multi-agent brief crew with CrewAI

Lesson 10 · Building Your Own Agents · ~45 min hands-on

Goal: at the end of this lab you will have (a) run agent.py against a topic and seen the 3-agent trace, (b) inspected the structured BriefDraft output, (c) swapped one agent’s model to a cheaper one and noted the cost change, and (d) modified the reviewer’s backstory and seen the resulting behaviour change. This is CrewAI’s “sweet spot” shape — a small team of role-typed agents collaborating on a brief.

Prereqs

Step 1 — Read agents/lesson10-multi-agent-crew/agent.py

The full program lives in the repo at agents/lesson10-multi-agent-crew/agent.py. Below it is reproduced inline so you can read along without flipping windows. We’ll then walk through each block.

Open the file in your editor — you don’t need to retype anything; the file is already there. The inlined copy in this lab guide is for reading convenience.

Step 2 — The full program

Here is the entire agent.py, verbatim:

"""Multi-agent brief-writing crew — Lesson 10 of the Agents course.

Built around CrewAI 0.86 primitives (Agent, Task, Crew, Process). All
LLM calls go through LiteLLM to `anthropic/claude-3-5-sonnet-latest`
so the cost/latency comparison in Module 6 is apples-to-apples with
Modules 1-4.

Three roles:
  - planner   : drafts a 3-section outline; has tools=[topic_lookup]
  - worker    : turns the outline into a BriefDraft (Pydantic)
  - reviewer  : flags unsupported claims or unclear sentences

Run:
    export ANTHROPIC_API_KEY=...
    python agent.py "What changed in Linux kernel 6.14's networking stack?"

NOTE: this file is also inlined in lab_guide.html. If you edit it,
also update the inlined block there.
"""
from __future__ import annotations

import sys
from typing import Any

from crewai import Agent, Crew, Process, Task
from crewai.tools import tool
from pydantic import BaseModel

# --- Mocked knowledge base ---------------------------------------------

KB: dict[str, str] = {
    "linux kernel 6.14 networking": (
        "Linux 6.14 networking: io_uring zero-copy TX for TCP sockets, "
        "BIG TCP enabled by default for IPv4 (was IPv6-only), and a new "
        "BPF kfunc family for tc-bpf flow tracking."
    ),
    "linux kernel 6.14 filesystem": (
        "Linux 6.14 filesystem: bcachefs marked stable, several "
        "ext4 small-file performance improvements."
    ),
}

MODEL = "anthropic/claude-3-5-sonnet-latest"

# --- Tools -------------------------------------------------------------

@tool("topic_lookup")
def topic_lookup(topic: str) -> str:
    """Look up a topic in the in-process knowledge base.

    Use this BEFORE planning the outline to ground the brief in real
    facts. Returns the KB entry text, or 'no entry found in KB'.
    """
    return KB.get(topic.lower(), "no entry found in KB")


# --- Structured outputs -------------------------------------------------

class BriefSection(BaseModel):
    heading: str
    body: str


class BriefDraft(BaseModel):
    title: str
    sections: list[BriefSection]


# --- Agents -------------------------------------------------------------

planner = Agent(
    role="Brief Planner",
    goal="Produce a 3-section outline for the brief — one line per section.",
    backstory=(
        "You are a structured thinker who refuses fluff. You ground every "
        "outline in real facts via the topic_lookup tool before writing."
    ),
    llm=MODEL,
    tools=[topic_lookup],
    verbose=True,
    allow_delegation=False,
)

worker = Agent(
    role="Brief Writer",
    goal="Turn the planner's outline into a structured BriefDraft.",
    backstory=(
        "You are a concise technical writer. You follow the outline EXACTLY "
        "and never invent facts that aren't in the planner's notes."
    ),
    llm=MODEL,
    verbose=True,
    allow_delegation=False,
)

reviewer = Agent(
    role="Skeptical Reviewer",
    goal="Flag any unsupported claims or unclear sentences in the draft.",
    backstory=(
        "You are a rigorous reviewer. You are paid to find issues. "
        "Saying 'no issues' too often is a failure mode."
    ),
    llm=MODEL,
    verbose=True,
    allow_delegation=False,
)

# --- Tasks --------------------------------------------------------------

plan_task = Task(
    description=(
        "Plan a brief on the topic: {topic}.\n"
        "Step 1: call topic_lookup({topic}) to fetch the KB entry.\n"
        "Step 2: output 3 section headings, one line each. No prose."
    ),
    expected_output="3 lines, each a section heading.",
    agent=planner,
)

draft_task = Task(
    description=(
        "Draft a brief on {topic} following the planner's outline EXACTLY. "
        "Use only facts present in the planner's notes; do not invent. "
        "Return a BriefDraft object."
    ),
    expected_output=(
        "A BriefDraft with a title and one BriefSection per outline heading."
    ),
    agent=worker,
    context=[plan_task],
    output_pydantic=BriefDraft,
)

review_task = Task(
    description=(
        "Review the draft for unsupported claims or unclear sentences. "
        "Refer back to the planner's outline if needed."
    ),
    expected_output=(
        "A short review: list 0-3 issues, or write 'no issues found'."
    ),
    agent=reviewer,
    context=[plan_task, draft_task],
)

# --- Crew + entry point --------------------------------------------------

def run_brief_crew(topic: str) -> dict[str, Any]:
    """Run the brief-writing crew and return a structured result.

    Plan F (Lesson 11) imports this function to wire CrewAI into the
    cross-framework scorecard. Keep the signature stable.
    """
    crew = Crew(
        agents=[planner, worker, reviewer],
        tasks=[plan_task, draft_task, review_task],
        process=Process.sequential,
        verbose=True,
    )
    result = crew.kickoff(inputs={"topic": topic})
    draft = draft_task.output.pydantic  # BriefDraft instance
    return {
        "outline": str(plan_task.output),
        "draft": draft.model_dump() if draft else None,
        "review": str(review_task.output),
        "final": str(result),
        "usage": getattr(crew, "usage_metrics", None),
    }


# --- CLI ----------------------------------------------------------------

if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit("usage: python agent.py 'your topic'")
    out = run_brief_crew(sys.argv[1])
    print("\n=== outline ===")
    print(out["outline"])
    print("\n=== draft ===")
    print(out["draft"])
    print("\n=== review ===")
    print(out["review"])
    print("\n=== usage ===")
    print(out["usage"])

Authoring tip

If you tweak agent.py later, remember to update this inlined block too — the script’s top-of-file docstring has a comment reminding you. The >, <, and & characters above are HTML-escaped; type hints like list[BriefSection] don’t need escaping, but the -> return arrows do.

Step 3 — Run it

From the repo root:

python agents/lesson10-multi-agent-crew/agent.py "What changed in Linux kernel 6.14's networking stack?"

Expected output: a stream of verbose=True log lines from each agent in turn (planner first, then worker, then reviewer), followed by four printed sections — === outline ===, === draft ===, === review ===, and === usage ===. The total cost reported by CrewAI in the usage block should be roughly $0.04–$0.08 with Sonnet across all three agents.

✓ Self-check: you saw log lines from all three agents in order, a populated BriefDraft in the draft section, and a non-empty review. No traceback.

Step 4 — Walk through the file

A guided tour, top to bottom:

Step 5 — Swap the worker to a cheaper model

Edit the worker = Agent(...) block and change llm=MODEL to:

llm="anthropic/claude-3-5-haiku-latest",

Re-run on the same topic:

python agents/lesson10-multi-agent-crew/agent.py "What changed in Linux kernel 6.14's networking stack?"

Expected: the total cost in the === usage === block drops noticeably — Haiku is roughly 5× cheaper than Sonnet on output tokens. Quality of the draft may or may not drop visibly; for a pure outline → prose transformation, Haiku usually holds up. This is the canonical CrewAI cost-optimisation move: downgrade the model on the agents whose job is mechanical, keep Sonnet on the ones that need judgement (planner, reviewer).

✓ Self-check: the usage block shows a lower total cost than the Sonnet-only baseline. Restore llm=MODEL before moving on.

Step 6 — Strengthen the reviewer’s backstory; observe behaviour

Edit the reviewer’s backstory field to append one sentence:

backstory=(
    "You are a rigorous reviewer. You are paid to find issues. "
    "Saying 'no issues' too often is a failure mode. "
    "Always find at least one issue, even if it's minor. "
    "Reviewers who never disagree provide no value."
),

Re-run. Expected: the reviewer now finds an issue on essentially every run — sometimes a real one, sometimes a stretch. This shows how backstory steers behaviour: the prompt asymmetry between “find issues” and “find at least one issue” is enough to flip the agent from skeptical-but-fair to actively-critical.

✓ Self-check: the review section is no longer “no issues found”. Restore the original backstory before moving on.

Step 7 — Drop the reviewer’s access to the plan

Edit review_task and remove plan_task from its context list:

review_task = Task(
    description=(
        "Review the draft for unsupported claims or unclear sentences. "
        "Refer back to the planner's outline if needed."
    ),
    expected_output=(
        "A short review: list 0-3 issues, or write 'no issues found'."
    ),
    agent=reviewer,
    context=[draft_task],   # was: [plan_task, draft_task]
)

Re-run. Expected: the reviewer’s critique no longer compares the draft to the plan — it just reviews the draft on its own merits. In some runs the reviewer misses cases where the draft diverged from the outline, because it can’t see the outline anymore.

✓ Self-check: the review reads as if the reviewer never saw the plan. Restore context=[plan_task, draft_task] before moving on.

Step 8 — Trigger a tool error

Edit topic_lookup to fail unconditionally:

@tool("topic_lookup")
def topic_lookup(topic: str) -> str:
    """Look up a topic in the in-process knowledge base."""
    raise RuntimeError("KB temporarily unavailable")

Re-run the same topic. Expected: the planner sees the tool error and either (a) plans an outline anyway based on the topic string alone, or (b) refuses and explains the problem in its outline. The crew does not crash — CrewAI surfaces tool errors as text the LLM sees on its next turn, the same shape as M2’s is_error: true tool result.

✓ Self-check: the run completes, even though the planner’s tool blew up. Restore the original topic_lookup body before moving on.

What you should leave with

Troubleshooting

Common stumbles