Agentic AI Evaluation: Task Completion, Tool Calls, and Trajectory Scoring

Agentic AI Evaluation: Task Completion, Tool Calls, and Trajectory Scoring

Agentic AI evaluation goes beyond single-turn accuracy. Measure task completion rate, tool-call precision/recall, step efficiency, and trajectory quality. A 90% single-turn accuracy agent can still fail 40% of multi-step tasks due to error propagation. Evaluate the full trajectory, not just the final answer.

Single-turn LLM evaluation is well understood: give the model a prompt, compare its output to a reference, compute a score. Agentic evaluation is fundamentally different. An agent takes sequences of actions — calling tools, reading intermediate results, deciding next steps — and errors compound across steps in ways that single-turn metrics completely miss.

An agent that is 90% accurate at each individual step will complete a 10-step task correctly only 35% of the time (0.9^10 ≈ 0.35). This is why you need trajectory-level evaluation, not just step-level accuracy.

What Agentic Evaluation Measures

Agentic evaluation covers four dimensions:

Dimension What It Measures Why It Matters
Task Completion Rate Did the agent accomplish the goal? The only metric users actually care about
Tool-Call Accuracy Did the agent call the right tools with the right args? Tool errors cascade through the trajectory
Step Efficiency Did the agent use the minimum necessary steps? Over-calling wastes tokens and money
Trajectory Quality Did the agent follow a sensible path even when the goal was met? Brittle paths fail on edge cases

Setting Up an Agent Test Framework

from dataclasses import dataclass, field
from typing import Any, Callable, List, Optional
import json
import time

@dataclass
class ToolCall:
    tool_name: str
    arguments: dict
    result: Any
    timestamp: float = field(default_factory=time.time)
    error: Optional[str] = None

@dataclass
class AgentTrajectory:
    task: str
    steps: List[ToolCall] = field(default_factory=list)
    final_answer: Optional[str] = None
    completed: bool = False
    elapsed_seconds: float = 0.0

    def add_step(self, tool_name: str, arguments: dict, result: Any, error: str = None):
        self.steps.append(ToolCall(tool_name, arguments, result, error=error))

@dataclass
class TaskSpec:
    task_id: str
    description: str
    expected_tool_sequence: List[str]           # ordered list of tool names that should be called
    required_tool_calls: List[dict]             # specific (tool, args) pairs that must appear
    success_condition: Callable[[AgentTrajectory], bool]
    max_steps: int = 15
    optimal_steps: int = 5

Multi-Step Task Completion Metrics

Task Completion Rate

from typing import List, Tuple
import numpy as np

def evaluate_task_completion(
    agent_fn: Callable[[str], AgentTrajectory],
    task_specs: List[TaskSpec],
) -> dict:
    """
    Run agent on all tasks and compute completion metrics.
    agent_fn: takes task description, returns AgentTrajectory
    """
    results = []

    for spec in task_specs:
        start = time.time()
        trajectory = agent_fn(spec.description)
        trajectory.elapsed_seconds = time.time() - start

        completed = spec.success_condition(trajectory)
        trajectory.completed = completed

        results.append({
            "task_id": spec.task_id,
            "completed": completed,
            "steps_taken": len(trajectory.steps),
            "optimal_steps": spec.optimal_steps,
            "max_steps": spec.max_steps,
            "step_efficiency": spec.optimal_steps / max(len(trajectory.steps), 1),
            "elapsed_seconds": trajectory.elapsed_seconds,
            "trajectory": trajectory,
        })

    completion_rate = np.mean([r["completed"] for r in results])
    avg_efficiency = np.mean([r["step_efficiency"] for r in results if r["completed"]])
    avg_steps = np.mean([r["steps_taken"] for r in results])

    return {
        "task_completion_rate": completion_rate,
        "avg_step_efficiency": avg_efficiency,
        "avg_steps_taken": avg_steps,
        "results": results,
    }

Partial Credit for Partial Completion

Many tasks have sub-goals. An agent that completes 3 of 4 sub-goals should score better than one that completes none:

@dataclass
class SubGoal:
    name: str
    check: Callable[[AgentTrajectory], bool]
    weight: float = 1.0

def score_partial_completion(trajectory: AgentTrajectory, subgoals: List[SubGoal]) -> float:
    """Return weighted fraction of subgoals achieved."""
    total_weight = sum(sg.weight for sg in subgoals)
    achieved_weight = sum(sg.weight for sg in subgoals if sg.check(trajectory))
    return achieved_weight / total_weight if total_weight > 0 else 0.0

# Example: task = "Search for Python docs, summarize the section on decorators, save to file"
subgoals = [
    SubGoal(
        "search_executed",
        lambda t: any(s.tool_name == "web_search" for s in t.steps),
        weight=1.0,
    ),
    SubGoal(
        "decorator_content_found",
        lambda t: any(
            "decorator" in str(s.result).lower()
            for s in t.steps if s.tool_name == "web_search"
        ),
        weight=2.0,
    ),
    SubGoal(
        "file_saved",
        lambda t: any(s.tool_name == "write_file" for s in t.steps),
        weight=1.0,
    ),
    SubGoal(
        "summary_in_file",
        lambda t: any(
            s.tool_name == "write_file" and len(str(s.arguments.get("content", ""))) > 100
            for s in t.steps
        ),
        weight=2.0,
    ),
]

Tool-Call Accuracy Evaluation

Precision and Recall Over Tool Sequences

def evaluate_tool_call_accuracy(
    trajectory: AgentTrajectory,
    spec: TaskSpec,
) -> dict:
    """
    Measure how well the agent's tool usage matches the expected pattern.
    """
    actual_tools = [step.tool_name for step in trajectory.steps]
    expected_tools = spec.expected_tool_sequence

    # Tool usage precision: fraction of actual tool calls that were appropriate
    actual_set = set(actual_tools)
    expected_set = set(expected_tools)

    precision = len(actual_set & expected_set) / len(actual_set) if actual_set else 0.0
    recall = len(actual_set & expected_set) / len(expected_set) if expected_set else 0.0
    f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0

    # Order correctness: did tools appear in the right sequence?
    order_score = _sequence_alignment_score(actual_tools, expected_tools)

    # Required call accuracy: were mandatory calls made with correct arguments?
    required_accuracy = _check_required_calls(trajectory, spec.required_tool_calls)

    return {
        "tool_precision": precision,
        "tool_recall": recall,
        "tool_f1": f1,
        "order_score": order_score,
        "required_call_accuracy": required_accuracy,
        "hallucinated_tools": list(actual_set - expected_set),
        "missed_tools": list(expected_set - actual_set),
    }

def _sequence_alignment_score(actual: List[str], expected: List[str]) -> float:
    """Longest common subsequence ratio as sequence alignment score."""
    m, n = len(actual), len(expected)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if actual[i-1] == expected[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    lcs = dp[m][n]
    return lcs / max(m, n) if max(m, n) > 0 else 0.0

def _check_required_calls(trajectory: AgentTrajectory, required_calls: List[dict]) -> float:
    """Check if all required (tool, args) pairs were made."""
    if not required_calls:
        return 1.0

    satisfied = 0
    for req in required_calls:
        tool_name = req["tool"]
        required_args = req.get("args", {})

        for step in trajectory.steps:
            if step.tool_name == tool_name:
                # Check if all required args match
                if all(
                    step.arguments.get(k) == v
                    for k, v in required_args.items()
                ):
                    satisfied += 1
                    break

    return satisfied / len(required_calls)

Trajectory Scoring and Failure Analysis

Trajectory Quality Score

A complete trajectory can still be low quality: unnecessary loops, redundant calls, or a lucky path that would fail with slight variation.

def score_trajectory_quality(trajectory: AgentTrajectory, spec: TaskSpec) -> dict:
    steps = trajectory.steps

    # 1. Efficiency ratio
    efficiency = spec.optimal_steps / max(len(steps), 1)

    # 2. Error rate: fraction of steps that returned an error
    error_steps = [s for s in steps if s.error is not None]
    error_rate = len(error_steps) / max(len(steps), 1)

    # 3. Redundancy: repeated identical tool+args combos
    seen = set()
    redundant = 0
    for step in steps:
        key = (step.tool_name, json.dumps(step.arguments, sort_keys=True))
        if key in seen:
            redundant += 1
        seen.add(key)
    redundancy_rate = redundant / max(len(steps), 1)

    # 4. Recovery rate: after an error, did the agent recover?
    recoveries = 0
    for i, step in enumerate(steps):
        if step.error and i + 1 < len(steps):
            next_step = steps[i + 1]
            if next_step.error is None:
                recoveries += 1
    recovery_rate = recoveries / max(len(error_steps), 1) if error_steps else 1.0

    # Composite quality score
    quality = (
        0.35 * min(efficiency, 1.0) +
        0.30 * (1.0 - error_rate) +
        0.20 * (1.0 - redundancy_rate) +
        0.15 * recovery_rate
    )

    return {
        "quality_score": quality,
        "efficiency_ratio": efficiency,
        "error_rate": error_rate,
        "redundancy_rate": redundancy_rate,
        "recovery_rate": recovery_rate,
        "total_steps": len(steps),
        "error_steps": len(error_steps),
        "redundant_steps": redundant,
    }

Failure Mode Classification

from enum import Enum

class FailureMode(Enum):
    WRONG_TOOL = "wrong_tool"           # called a tool that didn't exist or was inappropriate
    BAD_ARGUMENTS = "bad_arguments"     # right tool, wrong args
    INFINITE_LOOP = "infinite_loop"     # repeated same action without progress
    PREMATURE_STOP = "premature_stop"   # stopped before completing the task
    HALLUCINATED_RESULT = "hallucinated_result"  # used a result that wasn't returned
    CONTEXT_LOSS = "context_loss"       # forgot earlier results and re-queried

def classify_failure(trajectory: AgentTrajectory, spec: TaskSpec) -> List[FailureMode]:
    failures = []
    steps = trajectory.steps

    # Check for wrong tools
    valid_tools = set(spec.expected_tool_sequence)
    for step in steps:
        if step.tool_name not in valid_tools and step.error:
            failures.append(FailureMode.WRONG_TOOL)
            break

    # Check for infinite loops (same tool+args 3+ times)
    call_counts = {}
    for step in steps:
        key = (step.tool_name, json.dumps(step.arguments, sort_keys=True))
        call_counts[key] = call_counts.get(key, 0) + 1
        if call_counts[key] >= 3:
            failures.append(FailureMode.INFINITE_LOOP)
            break

    # Check for premature stop
    if not trajectory.completed and len(steps) < spec.max_steps:
        failures.append(FailureMode.PREMATURE_STOP)

    return failures

End-to-End Evaluation Pipeline

def run_agent_evaluation(
    agent_fn: Callable,
    task_specs: List[TaskSpec],
    verbose: bool = True,
) -> dict:
    all_results = []

    for spec in task_specs:
        trajectory = agent_fn(spec.description)
        completed = spec.success_condition(trajectory)
        trajectory.completed = completed

        completion_metrics = {
            "task_id": spec.task_id,
            "completed": completed,
            "steps": len(trajectory.steps),
        }
        tool_metrics = evaluate_tool_call_accuracy(trajectory, spec)
        quality_metrics = score_trajectory_quality(trajectory, spec)
        failures = classify_failure(trajectory, spec) if not completed else []

        result = {**completion_metrics, **tool_metrics, **quality_metrics, "failures": [f.value for f in failures]}
        all_results.append(result)

        if verbose:
            status = "PASS" if completed else "FAIL"
            print(f"[{status}] {spec.task_id}: quality={quality_metrics['quality_score']:.2f}, "
                  f"tool_f1={tool_metrics['tool_f1']:.2f}, steps={completion_metrics['steps']}")
            if failures:
                print(f"       Failures: {', '.join(f.value for f in failures)}")

    summary = {
        "task_completion_rate": np.mean([r["completed"] for r in all_results]),
        "avg_tool_f1": np.mean([r["tool_f1"] for r in all_results]),
        "avg_quality_score": np.mean([r["quality_score"] for r in all_results]),
        "avg_steps": np.mean([r["steps"] for r in all_results]),
        "failure_distribution": {},
    }

    for r in all_results:
        for f in r["failures"]:
            summary["failure_distribution"][f] = summary["failure_distribution"].get(f, 0) + 1

    return {"summary": summary, "per_task": all_results}

Pytest Integration

import pytest

TASK_SPECS = [
    TaskSpec(
        task_id="search_and_summarize",
        description="Search for 'Python async tutorial' and summarize the top result in 3 bullet points.",
        expected_tool_sequence=["web_search", "read_url", "respond"],
        required_tool_calls=[{"tool": "web_search", "args": {"query": "Python async tutorial"}}],
        success_condition=lambda t: t.completed and any(s.tool_name == "respond" for s in t.steps),
        optimal_steps=3,
        max_steps=8,
    ),
]

@pytest.mark.parametrize("spec", TASK_SPECS, ids=[s.task_id for s in TASK_SPECS])
def test_agent_task_completion(agent_fn, spec):
    trajectory = agent_fn(spec.description)
    assert spec.success_condition(trajectory), (
        f"Task '{spec.task_id}' not completed. Steps taken: {[s.tool_name for s in trajectory.steps]}"
    )

@pytest.mark.parametrize("spec", TASK_SPECS, ids=[s.task_id for s in TASK_SPECS])
def test_agent_tool_accuracy(agent_fn, spec):
    trajectory = agent_fn(spec.description)
    metrics = evaluate_tool_call_accuracy(trajectory, spec)
    assert metrics["required_call_accuracy"] == 1.0, (
        f"Required tool calls not made. Missing: {metrics['missed_tools']}"
    )
    assert metrics["tool_precision"] >= 0.80, (
        f"Tool precision {metrics['tool_precision']:.2f} too low. Hallucinated: {metrics['hallucinated_tools']}"
    )

@pytest.mark.parametrize("spec", TASK_SPECS, ids=[s.task_id for s in TASK_SPECS])
def test_agent_trajectory_quality(agent_fn, spec):
    trajectory = agent_fn(spec.description)
    quality = score_trajectory_quality(trajectory, spec)
    assert quality["quality_score"] >= 0.60, (
        f"Trajectory quality {quality['quality_score']:.2f} too low. "
        f"Errors: {quality['error_rate']:.2f}, Redundancy: {quality['redundancy_rate']:.2f}"
    )

What Good Looks Like

A production-ready agent should hit these thresholds before deployment:

  • Task completion rate: >80% on your benchmark task set
  • Tool F1: >0.85 (the agent uses the right tools and avoids phantom ones)
  • Trajectory quality score: >0.70 (efficient, low error rate, recovers from failures)
  • Required call accuracy: 1.0 (mandatory steps are never skipped)

If your agent passes single-turn benchmarks but fails trajectory tests, the usual culprits are: context window overflow on long tasks, poor error recovery prompting, or tool argument schema violations that the model ignores under pressure. The trajectory scorer will tell you exactly which failure mode to fix first.

Wrapping Up

Agentic AI evaluation requires measuring the full trajectory, not just the final answer. An agent that is accurate step-by-step can still fail tasks at scale due to compounding errors. Build evaluation infrastructure that captures task completion rate, tool-call accuracy, step efficiency, and trajectory quality as separate dimensions — then use failure mode classification to direct your debugging effort to the right layer.

Read more

Start now free