feat: Revision third-party eval metrics adapter (DeepEval + Autoevals)#568
feat: Revision third-party eval metrics adapter (DeepEval + Autoevals)#568stone-coding wants to merge 14 commits into
Conversation
Introduces a new integrations/deepeval/ module that adapts AgentCore Lambda evaluation events into DeepEval LLMTestCase objects, runs any BaseMetric, and returns structured score/label/explanation responses.
…leTurnParams deprecation
…d EvaluatorInput support
… layer, simplify per TJ/Irene feedback
…ion, add validate_fields to DeepEvalAdapter
…xpected_response_text property
- Rename span_parsers → span_mappers, simplify to Strands-only - Rename field_mapper → customer_mapper across all adapters - customer_mapper now returns native types directly: - DeepEval: EvaluatorInput → LLMTestCase - Autoevals: EvaluatorInput → Dict[str, Any] (eval kwargs) - Refactor BaseAdapter: remove intermediate execute(), add _run() pattern - 83 unit tests passing
| customer_mapper=lambda ev: { | ||
| "input": ev.session_spans[0]["attributes"]["question"], | ||
| "output": ev.session_spans[0]["attributes"]["answer"], | ||
| "expected": "the expected answer", | ||
| }, |
There was a problem hiding this comment.
Replaced lambda examples with named functions in docstrings
| def __init__( | ||
| self, | ||
| scorer: Any, | ||
| customer_mapper: Optional[Callable[[EvaluatorInput], Dict[str, Any]]] = None, |
There was a problem hiding this comment.
- We have not updated this type Dict[str, Any]?
- Regarding naming, "Custom mapper" would be a better choice for describing an arbitrary mapper provided by a customer?
There was a problem hiding this comment.
1.The type Dict[str, Any] is correct — Autoevals has no typed
input class like DeepEval's LLMTestCase. Scorers take plain
kwargs (input, output, expected) and the dict gets unpacked
as metric.eval(**kwargs). Different scorers need different
keys, so a generic dict is the right contract.
| self, | ||
| scorer: Any, | ||
| customer_mapper: Optional[Callable[[EvaluatorInput], Dict[str, Any]]] = None, | ||
| threshold: float = 0.5, |
There was a problem hiding this comment.
label: Optional[str] = None label in EvaluatorOutput is optional.
We don't need to have a default value to generate a label when a metric doesn't have a label and the user doesn't provide any threshold. Can we set default as None?
There was a problem hiding this comment.
Changed threshold default to None. When no threshold is set,
no Pass/Fail judgment is made. Note: EvaluatorOutput's
validator currently requires a label for success responses —
I'm returning the score as the label string when threshold is
None. Let me know if you'd rather relax the validator to
allow label=None.
| mapping when provided. Expected keys: input, output, expected (optional). | ||
| threshold: Score threshold for Pass/Fail determination. Defaults to 0.5. | ||
| """ | ||
| self.scorer = scorer |
There was a problem hiding this comment.
this naming is not aligned with DeepEval adaptor. Looks like you haven't updated AutoevalsAdapter.
If you haven't completed end to end tests for AutoevalsAdapter, you should not include it in your PR.
There was a problem hiding this comment.
Working on E2E test plan now. Considering how to strcuture parameterized Lambda handlers and how to obtains span from strands, openinference, opentelemetry, and customer mappers.
| customer_mapper=lambda ev: LLMTestCase( | ||
| input=ev.session_spans[0]["attributes"]["user_query"], | ||
| actual_output=ev.session_spans[0]["attributes"]["response"], | ||
| ), |
| def __init__( | ||
| self, | ||
| metric: BaseMetric, | ||
| customer_mapper: Optional[Callable[[EvaluatorInput], LLMTestCase]] = None, |
| self.metric = metric | ||
| self.customer_mapper = customer_mapper | ||
| if model is not None: | ||
| self.metric.model = model |
| Raises: | ||
| ValueError: If no mapper can extract data from the spans. | ||
| """ | ||
| result = _strands_mapper.map(session_spans) |
There was a problem hiding this comment.
We can check the span scope name and then decide which mapper to use.
There was a problem hiding this comment.
Applied to all three mappers (Strands, OpenInference LangChain, OpenTelemetry LangChain).
| attributes = span.get("attributes", {}) | ||
| if attributes.get("gen_ai.operation.name") == "invoke_agent": | ||
| return span | ||
| return None |
There was a problem hiding this comment.
Does this mapper only take the first agent span? For strands, the OtelSpanMapper in OpenTelemetryMapper package use the last agent span. That is, the agent span with the latest end_time per trace is the one retained.
There was a problem hiding this comment.
Fixed, now takes the last agent span to match server-side OtelSpanMapper behavior. Applied to all three mappers (Strands, OpenInference LangChain, OpenTelemetry LangChain).
| expected_output=result.expected_output, | ||
| context=result.context, | ||
| retrieval_context=result.retrieval_context, | ||
| ) |
There was a problem hiding this comment.
why don't we have tools_called and expected_tools?
Do we want to support ToolCorrectnessMetric, ArgumentCorrectnessMetric in DeepEval?
There was a problem hiding this comment.
Added both. tools_called is now extracted from execute_tool spans (name + input_parameters + output) and mapped to LLMTestCase.tools_called. expected_tools comes from reference_inputs[0].expectedTrajectory.toolNames → LLMTestCase.expected_tools. This enables ToolCorrectnessMetric and ArgumentCorrectnessMetric.
| actual_output: Optional[str] = None | ||
| retrieval_context: Optional[List[str]] = None | ||
| context: Optional[List[str]] = None | ||
| system_prompt: Optional[str] = None |
There was a problem hiding this comment.
Are we using system_prompt for any metric?
There was a problem hiding this comment.
Currently no DeepEval or Autoevals metric consumes system_prompt, LLMTestCase doesn't have this field. I include it in SpanMapResult for two reasons: (1) alignment with the server-side AgentInvocationSpan which also extracts it, and (2) forward-compatibility, customers using custom_mapper can access it from the spans if a future metric needs it. Happy to remove if you feel it adds unnecessary surface area.
There was a problem hiding this comment.
No, I agree with you. We should keep it.
| expected = getattr(ref, "expected_response_text", None) | ||
| if expected: | ||
| result.expected_output = expected | ||
| return result |
There was a problem hiding this comment.
We only take the data for expected_output here, but why don't we use the expected_trajectory field and assertions in reference_inputs?
There was a problem hiding this comment.
updated. Now all three fields from reference_inputs are extracted:
- expectedResponse.text → SpanMapResult.expected_output → LLMTestCase.expected_output
- expectedTrajectory.toolNames → SpanMapResult.expected_tools → LLMTestCase.expected_tools (enables ToolCorrectnessMetric)
- assertions[].text → SpanMapResult.assertions → LLMTestCase.context (enables HallucinationMetric to check agent response against ground-truth claims)
For the Autoevals adapter, assertions are joined as a fallback for the expected kwarg when no explicit expectedResponse is provided.
| input=input_text, | ||
| actual_output=actual_output, | ||
| retrieval_context=tool_outputs if tool_outputs else None, | ||
| context=tool_outputs if tool_outputs else None, |
There was a problem hiding this comment.
DeepEval uses context and retrieval_context for different metrics: HallucinationMetric requires context (ground-truth facts to check against), while FaithfulnessMetric requires retrieval_context (retrieved documents to verify grounding). Tool outputs serve both purposes — they're what the agent retrieved AND the factual basis for its response. Setting both ensures both metrics work without requiring a custom_mapper. Note: when assertions from reference_inputs are present, they override context (explicit ground truth takes
precedence over tool outputs). This matches the server-side pattern — the AgentInvocationSpan exposes tool results, and the service's built-in metrics use them as both retrieval context and grounding truth.
Summary
AgentCore's code-based evaluator framework
OTel spans — supports both inline events (unified ADOT) and span body (CloudWatch ADOT) formats
library's native type directly:
retrieval_context, system_prompt, expected_output)
method when building the test case
structured errorCode/errorMessage
Test plan