← Back to DevBytes

Building an Agent Evaluation Harness with CrewAI: Complete Guide

Introduction to Agent Evaluation Harnesses

Building AI agents is the easy part. Knowing whether they actually work — consistently, reliably, and at scale — is the hard part. When you ship a CrewAI crew into production, you need a way to measure how well it performs across a range of inputs, edge cases, and failure modes. That's where an agent evaluation harness comes in.

An evaluation harness is a structured framework that runs your agents against a predefined set of test cases, captures their outputs, scores them against expected outcomes or quality rubrics, and produces actionable metrics. Think of it as a unit test suite, but designed for the non-deterministic, probabilistic behavior of LLM-powered agents.

Why Evaluation Matters for CrewAI Agents

CrewAI agents differ from simple LLM calls. They reason, delegate, use tools, and collaborate with other agents. Each of those steps introduces variability. Without evaluation, you're flying blind when you:

A well-built harness gives you regression protection. Before you ship a change, you run the suite. If accuracy drops on any test case, you know immediately.

Core Components of an Evaluation Harness

Before writing code, let's define the architecture. A robust harness has five components:

Setting Up Your Project

Start by installing CrewAI and creating a project structure. We'll assume Python 3.10+.

pip install crewai crewai-tools python-dotenv pydantic

Create the following directory layout:

eval_harness/
├── crews/
│   └── research_crew.py
├── harness/
│   ├── __init__.py
│   ├── runner.py
│   ├── evaluators.py
│   ├── metrics.py
│   └── reporter.py
├── test_cases/
│   └── research_cases.json
└── run_evals.py

Building a Sample Crew to Evaluate

First, let's define a crew we can evaluate. This is a simple research crew with two agents: a researcher and a writer.

# crews/research_crew.py
from crewai import Agent, Task, Crew, Process
from crewai_tools import SerperDevTool

search_tool = SerperDevTool()

researcher = Agent(
    role="Senior Research Analyst",
    goal="Find accurate, up-to-date information on the given topic",
    backstory="You are an expert analyst with 15 years of experience "
              "synthesizing complex information into clear insights.",
    tools=[search_tool],
    verbose=False,
    allow_delegation=False,
)

writer = Agent(
    role="Technical Writer",
    goal="Produce a clear, well-structured report from research findings",
    backstory="You are a Pulitzer-nominated writer who excels at making "
              "technical topics accessible without losing accuracy.",
    verbose=False,
    allow_delegation=False,
)


def build_research_crew(topic: str) -> Crew:
    research_task = Task(
        description=f"Research the topic: '{topic}'. "
                    f"Find at least 3 key facts and 2 recent developments.",
        expected_output="A bullet list of key findings with sources.",
        agent=researcher,
    )

    writing_task = Task(
        description="Write a 300-word summary report based on the research findings.",
        expected_output="A polished markdown report with a title, summary, and key points.",
        agent=writer,
    )

    return Crew(
        agents=[researcher, writer],
        tasks=[research_task, writing_task],
        process=Process.sequential,
        verbose=False,
    )

Defining Test Cases

Test cases are the backbone of your harness. Each case should have an input, an expected output (or expected properties), and metadata. Store them as JSON for portability.

// test_cases/research_cases.json
[
  {
    "id": "rc_001",
    "category": "factual",
    "input": "the impact of quantum computing on cryptography",
    "expected_keywords": ["Shor", "RSA", "post-quantum", "encryption"],
    "min_word_count": 200,
    "max_word_count": 500
  },
  {
    "id": "rc_002",
    "category": "recent_events",
    "input": "latest developments in fusion energy",
    "expected_keywords": ["ITER", "tokamak", "net energy", "plasma"],
    "min_word_count": 200,
    "max_word_count": 500
  },
  {
    "id": "rc_003",
    "category": "edge_case",
    "input": "",
    "expected_keywords": [],
    "should_fail": true,
    "min_word_count": 0,
    "max_word_count": 0
  }
]

Notice the third test case — an empty input. Good harnesses test failure modes, not just happy paths. Your crew should handle this gracefully, and your harness should verify that it does.

Building the Runner

The runner executes the crew for each test case and captures the result. It needs to handle timeouts, exceptions, and output extraction.

# harness/runner.py
import time
import traceback
from dataclasses import dataclass, field
from typing import Any, Optional
from crewai import Crew


@dataclass
class RunResult:
    test_id: str
    success: bool
    output: Optional[str]
    error: Optional[str]
    duration_seconds: float
    metadata: dict = field(default_factory=dict)


def run_single_case(crew: Crew, test_case: dict, timeout: int = 120) -> RunResult:
    start = time.time()
    test_id = test_case["id"]

    try:
        result = crew.kickoff()
        elapsed = time.time() - start

        output = str(result) if result else ""

        return RunResult(
            test_id=test_id,
            success=True,
            output=output,
            error=None,
            duration_seconds=round(elapsed, 2),
            metadata={"raw_result": result.raw if hasattr(result, "raw") else None},
        )

    except Exception as e:
        elapsed = time.time() - start
        return RunResult(
            test_id=test_id,
            success=False,
            output=None,
            error=f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}",
            duration_seconds=round(elapsed, 2),
        )


def run_suite(crew_factory, test_cases: list, timeout: int = 120) -> list:
    results = []
    for case in test_cases:
        print(f"  Running test {case['id']}...")
        crew = crew_factory(case["input"])
        result = run_single_case(crew, case, timeout)
        results.append(result)
    return results

Writing Evaluators

Evaluators inspect the output and produce a score. You'll typically use a mix of deterministic checks (keyword presence, word count) and LLM-as-judge evaluations for subjective quality.

# harness/evaluators.py
import re
from dataclasses import dataclass
from typing import Callable


@dataclass
class EvalScore:
    name: str
    passed: bool
    score: float  # 0.0 to 1.0
    detail: str


def keyword_evaluator(output: str, expected_keywords: list) -> EvalScore:
    if not expected_keywords:
        return EvalScore("keyword_check", True, 1.0, "No keywords required.")

    output_lower = output.lower()
    found = [kw for kw in expected_keywords if kw.lower() in output_lower]
    score = len(found) / len(expected_keywords)
    missing = set(expected_keywords) - set(found)

    detail = f"Found {len(found)}/{len(expected_keywords)}."
    if missing:
        detail += f" Missing: {missing}"

    return EvalScore("keyword_check", score >= 0.5, score, detail)


def word_count_evaluator(output: str, min_words: int, max_words: int) -> EvalScore:
    word_count = len(re.findall(r"\b\w+\b", output))
    passed = min_words <= word_count <= max_words
    detail = f"Word count: {word_count} (expected {min_words}-{max_words})"
    return EvalScore("word_count", passed, 1.0 if passed else 0.0, detail)


def failure_evaluator(output: str, should_fail: bool, error: str) -> EvalScore:
    if should_fail:
        passed = error is not None and len(error) > 0
        return EvalScore("expected_failure", passed, 1.0 if passed else 0.0,
                         f"Expected failure, got: {'failure' if passed else 'success'}")
    return EvalScore("expected_failure", True, 1.0, "No failure expected.")


def evaluate_result(run_result, test_case: dict) -> list:
    scores = []

    if run_result.success and run_result.output:
        scores.append(keyword_evaluator(
            run_result.output, test_case.get("expected_keywords", [])
        ))
        scores.append(word_count_evaluator(
            run_result.output,
            test_case.get("min_word_count", 0),
            test_case.get("max_word_count", 10000),
        ))
    else:
        scores.append(EvalScore("keyword_check", False, 0.0, "No output produced."))

    scores.append(failure_evaluator(
        run_result.output or "",
        test_case.get("should_fail", False),
        run_result.error or "",
    ))

    return scores

Adding an LLM-as-Judge Evaluator

For subjective quality dimensions — clarity, accuracy, tone — use an LLM to judge the output. This is the most powerful evaluator type, but it adds cost and latency.

# Add to harness/evaluators.py
from crewai import Agent, Task, Crew, Process
from litellm import completion
import json
import os


def llm_judge_evaluator(output: str, input_topic: str, rubric: str) -> EvalScore:
    prompt = f"""You are an expert evaluator. Score the following AI-generated report.

Topic: {input_topic}

Report:
---
{output}
---

Evaluation rubric:
{rubric}

Respond ONLY with valid JSON in this format:
{{"score": 0.0-1.0, "reasoning": "one sentence explanation", "passed": true/false}}
"""

    try:
        response = completion(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0.0,
            api_key=os.getenv("OPENAI_API_KEY"),
        )
        content = response.choices[0].message.content.strip()
        # Strip markdown code fences if present
        if content.startswith(""):
            content = content.split("")[1]
            if content.startswith("json"):
                content = content[4:]
        result = json.loads(content)
        return EvalScore(
            "llm_judge",
            result.get("passed", result.get("score", 0) >= 0.7),
            float(result.get("score", 0.0)),
            result.get("reasoning", "No reasoning provided."),
        )
    except Exception as e:
        return EvalScore("llm_judge", False, 0.0, f"Judge error: {e}")

Aggregating Metrics

Once you have scores from all evaluators across all test cases, aggregate them into summary metrics. This is what you'll review after each run.

# harness/metrics.py
from collections import defaultdict
from dataclasses import dataclass, field


@dataclass
class SuiteMetrics:
    total_cases: int = 0
    passed_cases: int = 0
    failed_cases: int = 0
    pass_rate: float = 0.0
    avg_duration: float = 0.0
    evaluator_scores: dict = field(default_factory=dict)
    case_results: list = field(default_factory=list)


def aggregate(results: list, all_scores: list) -> SuiteMetrics:
    metrics = SuiteMetrics()
    metrics.total_cases = len(results)

    durations = []
    eval_totals = defaultdict(lambda: {"sum": 0.0, "count": 0})

    for run_result, scores in zip(results, all_scores):
        case_passed = all(s.passed for s in scores)
        if case_passed:
            metrics.passed_cases += 1
        else:
            metrics.failed_cases += 1

        durations.append(run_result.duration_seconds)

        case_detail = {
            "test_id": run_result.test_id,
            "success": run_result.success,
            "passed": case_passed,
            "duration": run_result.duration_seconds,
            "scores": [
                {"name": s.name, "passed": s.passed, "score": s.score, "detail": s.detail}
                for s in scores
            ],
        }
        if run_result.error:
            case_detail["error"] = run_result.error[:500]

        metrics.case_results.append(case_detail)

        for s in scores:
            eval_totals[s.name]["sum"] += s.score
            eval_totals[s.name]["count"] += 1

    metrics.pass_rate = metrics.passed_cases / metrics.total_cases if metrics.total_cases else 0
    metrics.avg_duration = round(sum(durations) / len(durations), 2) if durations else 0
    metrics.evaluator_scores = {
        name: round(v["sum"] / v["count"], 3) for name, v in eval_totals.items()
    }

    return metrics

Reporting Results

The reporter formats metrics for human consumption. A console reporter is essential for local development; a JSON reporter is essential for CI/CD pipelines.

# harness/reporter.py
import json


def report_console(metrics):
    print("\n" + "=" * 60)
    print("  EVALUATION SUITE RESULTS")
    print("=" * 60)
    print(f"  Total cases:  {metrics.total_cases}")
    print(f"  Passed:       {metrics.passed_cases}")
    print(f"  Failed:       {metrics.failed_cases}")
    print(f"  Pass rate:    {metrics.pass_rate:.1%}")
    print(f"  Avg duration: {metrics.avg_duration}s")
    print("-" * 60)
    print("  Evaluator Scores:")
    for name, score in metrics.evaluator_scores.items():
        status = "PASS" if score >= 0.7 else "WARN" if score >= 0.5 else "FAIL"
        print(f"    [{status}] {name:20s} {score:.3f}")
    print("-" * 60)
    print("  Case Details:")
    for case in metrics.case_results:
        status = "PASS" if case["passed"] else "FAIL"
        print(f"    [{status}] {case['test_id']} ({case['duration']}s)")
        for s in case["scores"]:
            flag = "+" if s["passed"] else "-"
            print(f"      {flag} {s['name']}: {s['detail']}")
    print("=" * 60 + "\n")


def report_json(metrics, filepath: str):
    data = {
        "total_cases": metrics.total_cases,
        "passed_cases": metrics.passed_cases,
        "failed_cases": metrics.failed_cases,
        "pass_rate": metrics.pass_rate,
        "avg_duration": metrics.avg_duration,
        "evaluator_scores": metrics.evaluator_scores,
        "case_results": metrics.case_results,
    }
    with open(filepath, "w") as f:
        json.dump(data, f, indent=2)
    print(f"JSON report saved to {filepath}")

Wiring It All Together

Now create the entry point that loads test cases, runs the suite, evaluates, aggregates, and reports.

# run_evals.py
import json
import os
from dotenv import load_dotenv

from crews.research_crew import build_research_crew
from harness.runner import run_suite
from harness.evaluators import evaluate_result
from harness.metrics import aggregate
from harness.reporter import report_console, report_json

load_dotenv()


def main():
    with open("test_cases/research_cases.json") as f:
        test_cases = json.load(f)

    print(f"Loaded {len(test_cases)} test cases. Starting evaluation...\n")

    results = run_suite(build_research_crew, test_cases, timeout=180)

    all_scores = []
    for run_result, test_case in zip(results, test_cases):
        scores = evaluate_result(run_result, test_case)
        all_scores.append(scores)

    metrics = aggregate(results, all_scores)
    report_console(metrics)
    report_json(metrics, "eval_results.json")

    # Exit code for CI integration
    exit(0 if metrics.pass_rate >= 0.8 else 1)


if __name__ == "__main__":
    main()

Run it with:

python run_evals.py

Best Practices

1. Version Your Test Cases

Keep test cases in version control alongside your crew definitions. When you change a prompt, you can immediately see which test cases regress. Tag test case files with the crew version they correspond to.

2. Use Deterministic Seeds Where Possible

LLM outputs are non-deterministic, but you can reduce variance by setting temperature to 0 during evaluation runs and pinning model versions. This won't eliminate all variance, but it makes results more reproducible.

3. Separate Fast and Slow Test Suites

Not every test needs to run on every commit. Create a fast suite (5-10 cases, deterministic evaluators only) for local development and a comprehensive suite (50+ cases, including LLM-judge) for pre-release validation.

4. Track Metrics Over Time

Save JSON results from each run with a timestamp. Over time, you'll build a picture of how your crew's performance evolves. A simple trend line of pass rate per commit is invaluable for catching gradual regressions.

5. Include Adversarial and Edge Cases

Happy-path tests give you a false sense of security. Include cases with empty inputs, extremely long inputs, ambiguous queries, and prompts designed to trigger tool misuse. Your harness should verify the crew fails gracefully, not just that it succeeds on easy inputs.

6. Cache Tool Calls During Evaluation

If your agents use web search or API tools, cache the responses during evaluation runs. This reduces cost, speeds up the suite, and makes results more reproducible. CrewAI tools can be wrapped with a caching layer using functools.lru_cache or a disk-based cache like diskcache.

7. Set Realistic Pass Thresholds

Don't set your CI gate at 100% pass rate. LLM variance means some cases will occasionally fail even with a good crew. A threshold of 80-90% with mandatory passing of critical cases is more practical. Mark critical cases explicitly in your test case JSON and enforce them separately.

Conclusion

Building an evaluation harness for your CrewAI agents transforms agent development from guesswork into a measurable, iterative process. By defining test cases, running your crew against them, scoring outputs with both deterministic and LLM-based evaluators, and tracking metrics over time, you gain the confidence to refactor prompts, swap models, and add tools without fear of silent regressions. Start small — a handful of test cases and two or three evaluators is enough to deliver immediate value — then expand your suite as your crews grow in complexity. The investment pays off the first time your harness catches a regression before your users do.

— Ad —

Google AdSense will appear here after approval

← Back to all articles