Lesson 10 A Multi-Agent Crew
Theme:

Solutions — Three Extensions of the Brief Crew

Lesson 10 · Building Your Own Agents · worked answers with commentary

Below is one clean answer per homework task, plus the why-this-not-that commentary that distinguishes a passing solution from an idiomatic CrewAI one. Compare your attempt against these — even a different-but-working solution is fine, but the commentary often surfaces traps you wouldn’t notice from a successful run alone.

How to read the solutions

Each section opens with the code diff against the lab’s agent.py, then a common mistake callout, then the broader lesson. The diffs are meant to drop into agent.py with minimal edits — copy-paste, re-run python agent.py "…", and you should see the new behaviour on the very next kickoff.

Solution to Task 1 — insert a fact-checker between worker and reviewer

Define a fourth agent, slot a new task between draft_task and review_task, and update the reviewer’s context= so it can read the fact-checker’s findings.

fact_checker = Agent(
    role="Fact Checker",
    goal="Verify each claim in the draft against the KB entry.",
    backstory=(
        "You are a literal-minded checker. If a claim isn't in the KB, "
        "you flag it. You never speculate."
    ),
    llm=MODEL,
    tools=[topic_lookup],
    verbose=True,
    allow_delegation=False,
)

fact_check_task = Task(
    description=(
        "Read the draft. For each claim, check it against the KB via "
        "topic_lookup({topic}). List any claims not supported by the KB."
    ),
    expected_output="A short list of unsupported claims, or 'all claims supported'.",
    agent=fact_checker,
    context=[plan_task, draft_task],
)

# review_task gets fact_check_task added to its context:
review_task = Task(
    description=(
        "Review the draft for unsupported claims or unclear sentences. "
        "Take the fact-checker's findings into account."
    ),
    expected_output="A short review: list 0-3 issues, or write 'no issues found'.",
    agent=reviewer,
    context=[plan_task, draft_task, fact_check_task],
)

# In run_brief_crew:
crew = Crew(
    agents=[planner, worker, fact_checker, reviewer],
    tasks=[plan_task, draft_task, fact_check_task, review_task],
    process=Process.sequential,
    verbose=True,
)

Common mistake — forgetting to extend the reviewer’s context

The natural reach is to add fact_check_task to the crew’s tasks=[] list and stop there. Don’t. CrewAI’s Process.sequential only passes explicitly-listed prior outputs to a task via context=[…]. If you leave review_task.context=[plan_task, draft_task] unchanged, the reviewer never sees the fact-checker’s findings — the new agent silently provides zero value to downstream tasks. Always update the downstream context= when you insert an agent.

Is the 4-agent crew better or just slower? It depends. The fact-checker is largely a redundant safety net — most of what it catches, the reviewer would have caught anyway. The wins: explicit separation (you can now bill fact-checking cost separately, swap the fact-checker for an automated KB diff later, or route just it through a different model). The losses: ~33% more cost and latency per run. For a high-stakes brief (legal, medical, security advisories) the safety net is worth it; for an internal weekly summary it’s overkill.

The deeper lesson: adding an agent always adds cost — the value has to come from specialisation the existing agents can’t provide, not from “more eyes on the work”. If the new agent’s persona overlaps heavily with an existing one, tighten the existing prompt instead.

Solution to Task 2 — per-agent model routing for a real cost win

Three one-line edits — one per agent’s llm= field. Everything else stays the same.

planner = Agent(
    role="Brief Planner",
    goal="Produce a 3-section outline for the brief — one line per section.",
    backstory="...",
    llm="anthropic/claude-3-5-sonnet-latest",  # unchanged — judgement-heavy
    tools=[topic_lookup],
    verbose=True,
    allow_delegation=False,
)

worker = Agent(
    role="Brief Writer",
    goal="Turn the planner's outline into a structured BriefDraft.",
    backstory="...",
    llm="anthropic/claude-3-5-haiku-latest",   # was sonnet — transformation only
    verbose=True,
    allow_delegation=False,
)

reviewer = Agent(
    role="Skeptical Reviewer",
    goal="Flag any unsupported claims or unclear sentences in the draft.",
    backstory="...",
    llm="anthropic/claude-3-5-sonnet-latest",  # unchanged — judgement-heavy
    verbose=True,
    allow_delegation=False,
)

Run the crew on three topics and watch the cost in out["usage"]. Expect a ~30–40% drop vs. the all-Sonnet baseline. The worker’s draft step is by far the most token-heavy part of the run (drafts have more output tokens than outlines or reviews), and Haiku is roughly 5× cheaper per output token than Sonnet. The brief quality should be essentially unchanged because the worker’s job is a constrained transformation (outline + KB facts → structured draft), not open-ended reasoning.

Common mistake — routing the reviewer to Haiku to save more money

Don’t. Review requires judgement (is this claim supported? is this sentence clear?) and Haiku is materially more likely to wave through a flawed draft. You’ll save another ~10% on cost and pay it back in undetected errors. The general rule: route the agent doing the most token-heavy transformation to the cheap model; keep judgement-heavy roles on the capable model.

The deeper lesson: per-agent model routing is the underrated CrewAI win. Most frameworks (LangGraph, OpenAI SDK, hand-rolled loops) make it awkward to pin different models to different steps — you end up threading model= through factory functions or maintaining two parallel agent definitions. CrewAI’s per-Agent llm= field makes it a one-line edit. Use this. It’s a free cost win on every crew with a heavy transformation step.

Solution to Task 3 — structured ReviewReport output

Define two Pydantic models, attach output_pydantic=ReviewReport to review_task, and surface the parsed object in run_brief_crew’s return dict.

from pydantic import BaseModel

class ReviewIssue(BaseModel):
    severity: str   # "low" | "medium" | "high"
    description: str

class ReviewReport(BaseModel):
    issues: list[ReviewIssue]
    overall: str    # "ship" | "revise" | "reject"


review_task = Task(
    description=(
        "Review the draft. For each issue, give a severity (low/medium/high) "
        "and a one-line description. Finish with an overall verdict: "
        "'ship', 'revise', or 'reject'."
    ),
    expected_output=(
        "A ReviewReport object with `issues` (a list of ReviewIssue with "
        "`severity` and `description`) and `overall` (one of 'ship', "
        "'revise', 'reject')."
    ),
    agent=reviewer,
    context=[plan_task, draft_task],
    output_pydantic=ReviewReport,
)


def run_brief_crew(topic: str) -> dict[str, Any]:
    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
    review = review_task.output.pydantic     # ReviewReport instance (or None)
    return {
        "outline": str(plan_task.output),
        "draft": draft.model_dump() if draft else None,
        "review": str(review_task.output),
        "review_struct": review.model_dump() if review else None,
        "final": str(result),
        "usage": getattr(crew, "usage_metrics", None),
    }

Common mistake — assuming .pydantic is always populated

Even with output_pydantic=ReviewReport, CrewAI 0.86 occasionally returns review_task.output.pydantic is None if the LLM’s output couldn’t be parsed into the schema. The fix is twofold: (1) tighten the expected_output= description — be explicit about the field names and the valid enum values for overall, as in the diff above — and (2) defensively guard with review.model_dump() if review else None so a single bad parse doesn’t crash the whole run. Downstream code that reads review_struct should also handle the None case.

The deeper lesson: typed task outputs are how multi-agent systems become programmatically actionable. When the reviewer’s verdict is the string "ship", downstream code can branch on it directly:

out = run_brief_crew("linux kernel 6.14 networking")
if out["review_struct"] and out["review_struct"]["overall"] == "ship":
    publish_brief(out["draft"])
elif out["review_struct"] and out["review_struct"]["overall"] == "revise":
    queue_for_revision(out["draft"], issues=out["review_struct"]["issues"])
else:
    alert_humans(out)

Without the typed output you’d need another LLM call to extract the verdict from free-form review prose — which is exactly what the typed output saved you. Whenever a task’s output is going to drive a programmatic decision (route, branch, alert), pay the small Pydantic-schema cost upfront and skip the parsing layer downstream.

What you’ve built

Across three small extensions you’ve added specialisation (fact-checker), cost-aware routing (per-agent model selection), and programmatic actionability (typed reviewer output) to the canonical CrewAI multi-agent shape. That’s the production template for crew-based agents in three pieces:

Closing tip: You’ve now built the canonical CrewAI multi-agent shape. Module 6 puts this crew side-by-side with the Claude SDK research assistant (M2), the LangGraph pipeline (M3), and the OpenAI handoff (M4) to answer “which framework, when?” with real cost, latency, and lines-of-code numbers.

← Back to homework