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
-
The
agents/requirements.txtalready installed (which pinscrewai>=0.80,<0.90). If not, runpip install -r agents/requirements.txtfrom the repo root. ANTHROPIC_API_KEYexported in your shell.-
L09 lab completed — you should be comfortable with
Agent,Task,Crew,Process.sequential, and thecontext=[...]wiring that feeds one task’s output to the next. - Working directory at the repo root (the paths below assume that).
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:
-
KB— the mocked knowledge base thetopic_lookuptool searches. Two entries are pre-seeded. To add a topic, appendKB["new topic"] = "...". The planner’s backstory tells it to calltopic_lookupfirst, so the brief stays grounded in real text instead of LLM hallucination. -
topic_lookup— the planner’s only tool. Notice the docstring explicitly says “use this BEFORE planning” — a backstory + docstring combo that nudges the planner to ground its outline before it writes one. -
BriefSectionandBriefDraft— the Pydantic models that shape the worker’s output. The worker is constrained byoutput_pydantic=BriefDraftondraft_task— CrewAI feeds the schema to the LLM and parses the result back into a typed object for you. -
The three agents — note that all three have
verbose=Trueandallow_delegation=False. Settingallow_delegation=Truewould let an agent ask another to do its task — useful forProcess.hierarchical, harmful here because it would blur the strict role boundaries we want for a sequential crew. -
plan_task— references{topic}, which is filled frominputs={...}onkickoff(). The description is an explicit two-step recipe: lookup, then outline. No prose. -
draft_task— hascontext=[plan_task]so the worker sees the outline. Hasoutput_pydantic=BriefDraftso the LLM is told the exact shape it must produce. -
review_task— hascontext=[plan_task, draft_task]so the reviewer can compare the draft to the plan. No tools — review is purely artefact-based, working from the prior two outputs. -
run_brief_crew(topic)— the entry point that Plan F (Lesson 11) will import. Returns a dict so the cross-framework scorecard can pick out individual fields (outline,draft,review,usage). Keep this signature stable. -
__main__block — CLI wrapper, prints all four sections.
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
agent.pyrunning end-to-end on a topic.-
First-hand observation of per-agent model swap, backstory editing, and
context=pruning effects. - A typed
BriefDraftobject you can hand to downstream code. - The instinct that CrewAI’s “team metaphor in code” division of labour fits a plan / do / review problem like a glove.
Troubleshooting
Common stumbles
-
ModuleNotFoundError: No module named 'crewai.tools'— yourcrewaiversion is older than 0.80. Re-install withpip install -r agents/requirements.txt. -
AuthenticationErrorfrom LiteLLM —ANTHROPIC_API_KEYis not exported, or you used a model string without theanthropic/prefix (LiteLLM then doesn’t know which provider to call). The correct string is"anthropic/claude-3-5-sonnet-latest". -
Cost surprises —
Process.sequentialwith three agents each on Sonnet typically lands at$0.04–$0.08per brief. If you’re seeing$0.20+, check whetherallow_delegation=Truegot flipped on somewhere — that lets agents call each other recursively, multiplying tokens. -
draft_task.output.pydantic is None— the worker produced text that CrewAI couldn’t parse intoBriefDraft. Tighten theexpected_output=string ondraft_taskand re-run. (CrewAI 0.86 retries once on parse failure but doesn’t always succeed.) - Reviewer is too agreeable — that’s a backstory problem. See Step 6 above for the “always find at least one issue” patch.