|
| 1 | +""" |
| 2 | +Agent Workbench - Evaluator |
| 3 | +
|
| 4 | +Applies SuccessCriteria to a completed AgentRun and produces CriteriaResult list. |
| 5 | +
|
| 6 | +Supported criteria types: |
| 7 | + no_error - run completed without an error |
| 8 | + tool_called - a specific tool name appears in tools_used |
| 9 | + output_contains - final output contains the substring (case-insensitive) |
| 10 | + llm_judge - the LLM grades the output via a judge prompt (requires LLM configuration) |
| 11 | +""" |
| 12 | + |
| 13 | +from typing import Any |
| 14 | + |
| 15 | +from .models import AgentRun, CriteriaResult, CriteriaType, SuccessCriteria |
| 16 | + |
| 17 | +# ============================================================================ |
| 18 | +# ASYNC LLM JUDGE (I/O) |
| 19 | +# ============================================================================ |
| 20 | + |
| 21 | +async def _eval_llm_judge( |
| 22 | + run: AgentRun, |
| 23 | + criteria: SuccessCriteria, |
| 24 | + llm: Any, # ChatOpenAI or compatible |
| 25 | +) -> CriteriaResult: |
| 26 | + """ |
| 27 | + Ask an LLM to evaluate whether the run output satisfies the judge prompt. |
| 28 | +
|
| 29 | + The criteria.value is a judge prompt that is appended with the run's |
| 30 | + output. The LLM must respond with PASS or FAIL (case-insensitive) as |
| 31 | + the first word. |
| 32 | + """ |
| 33 | + if llm is None: |
| 34 | + raise ValueError("llm_judge criteria require an LLM instance") |
| 35 | + |
| 36 | + judge_prompt = ( |
| 37 | + f"{criteria.value}\n\n" |
| 38 | + f"--- Agent Output ---\n{run.output or '(empty)'}\n\n" |
| 39 | + "Reply with a single word: PASS or FAIL, followed by an optional explanation." |
| 40 | + ) |
| 41 | + |
| 42 | + try: |
| 43 | + from langchain_core.messages import HumanMessage |
| 44 | + response = await llm.ainvoke([HumanMessage(content=judge_prompt)]) |
| 45 | + answer = (response.content or "").strip() |
| 46 | + passed = answer.upper().startswith("PASS") |
| 47 | + return CriteriaResult( |
| 48 | + criteria=criteria, |
| 49 | + passed=passed, |
| 50 | + detail=answer[:500], |
| 51 | + ) |
| 52 | + except Exception as exc: |
| 53 | + return CriteriaResult( |
| 54 | + criteria=criteria, |
| 55 | + passed=False, |
| 56 | + detail=f"LLM judge error: {exc}", |
| 57 | + ) |
| 58 | + |
| 59 | + |
| 60 | +# ============================================================================ |
| 61 | +# PUBLIC EVALUATOR |
| 62 | +# ============================================================================ |
| 63 | + |
| 64 | +async def evaluate_run( |
| 65 | + run: AgentRun, |
| 66 | + criteria_list: list[SuccessCriteria], |
| 67 | + llm: Any = None, |
| 68 | +) -> list[CriteriaResult]: |
| 69 | + """ |
| 70 | + Evaluate all criteria for a run. |
| 71 | +
|
| 72 | + Args: |
| 73 | + run: Completed AgentRun (status = completed | failed). |
| 74 | + criteria_list: List of SuccessCriteria from the AgentDefinition. |
| 75 | + llm: Optional LLM instance; required when criteria include llm_judge. |
| 76 | +
|
| 77 | + Returns: |
| 78 | + Ordered list of CriteriaResult, one per criterion. |
| 79 | + """ |
| 80 | + results: list[CriteriaResult] = [] |
| 81 | + |
| 82 | + for criteria in criteria_list: |
| 83 | + if criteria.type == CriteriaType.NO_ERROR: |
| 84 | + passed = run.error is None and run.status == "completed" |
| 85 | + results.append( |
| 86 | + CriteriaResult( |
| 87 | + criteria=criteria, |
| 88 | + passed=passed, |
| 89 | + detail="" if passed else f"Run error: {run.error or 'unexpected status ' + run.status}", |
| 90 | + ) |
| 91 | + ) |
| 92 | + elif criteria.type == CriteriaType.TOOL_CALLED: |
| 93 | + tool_name = criteria.value.strip() |
| 94 | + results.append( |
| 95 | + CriteriaResult( |
| 96 | + criteria=criteria, |
| 97 | + passed=tool_name in run.tools_used, |
| 98 | + detail=f"tools_used={run.tools_used}", |
| 99 | + ) |
| 100 | + ) |
| 101 | + elif criteria.type == CriteriaType.OUTPUT_CONTAINS: |
| 102 | + needle = criteria.value |
| 103 | + haystack = (run.output or "").lower() |
| 104 | + results.append( |
| 105 | + CriteriaResult( |
| 106 | + criteria=criteria, |
| 107 | + passed=needle.lower() in haystack, |
| 108 | + detail=f"searched for '{needle}' in output ({len(run.output or '')} chars)", |
| 109 | + ) |
| 110 | + ) |
| 111 | + elif criteria.type == CriteriaType.LLM_JUDGE: |
| 112 | + results.append(await _eval_llm_judge(run, criteria, llm)) |
| 113 | + else: |
| 114 | + results.append(CriteriaResult( |
| 115 | + criteria=criteria, |
| 116 | + passed=False, |
| 117 | + detail=f"Unknown criteria type: {criteria.type}", |
| 118 | + )) |
| 119 | + |
| 120 | + return results |
| 121 | + |
| 122 | + |
| 123 | +def compute_score(results: list[CriteriaResult]) -> float: |
| 124 | + """Returns the fraction of passed criteria (0.0 when no criteria).""" |
| 125 | + if not results: |
| 126 | + return 1.0 # vacuously true |
| 127 | + passed = sum(1 for r in results if r.passed) |
| 128 | + return round(passed / len(results), 4) |
0 commit comments