fix: DeepEvalAdapter to extract multi-turn conversations from service-normalized SESSION format - #622
fix: DeepEvalAdapter to extract multi-turn conversations from service-normalized SESSION format#622ybdarrenwang wants to merge 1 commit into
Conversation
stone-coding
left a comment
There was a problem hiding this comment.
Reviewed PR. The fix correctly handles the SESSION-level service-normalized format where multiple turns are collapsed into span_events[*].body. My original _extract_from_service_format() only covered single-turn gen_ai events.
…-normalized SESSION format
699048e to
338de68
Compare
| """ | ||
| import json as _json | ||
|
|
||
| # --- Multi-turn: extract from span_events[*].body --- |
There was a problem hiding this comment.
[P0] I think this still misses the exact one-span/many-event case this PR is trying to fix. When the CloudWatch mapper recognizes the span, it returns valid input/output from the first event, so the fallback in map_spans() never calls this new path. I reproduced this using real Strands invoke_agent bodies collapsed into the target shape: turns stayed None and DeepEval returned FIELD_EXTRACTION_ERROR. Could we run the service-format extraction when multiple body events are present even if the mapper found one valid pair, and retain the real scope/attributes in the regression test?
| adapter = AutoEvalsAdapter(metric=_mock_scorer()) | ||
| result = adapter(_make_evaluator_input(spans=spans)) | ||
| _assert_error_response(result, "FIELD_EXTRACTION_ERROR") | ||
| _assert_error_response(result, "MISSING_REQUIRED_FIELD") |
There was a problem hiding this comment.
[P0] I do not think this expected error code matches the current behavior. The full third-party suite has 2 failed, 115 passed: this case and the equivalent DeepEval test both still return FIELD_EXTRACTION_ERROR. These spans have no body or span_events, so the new extractor cannot reach MISSING_REQUIRED_FIELD. The base tests pass with the original expectation. Could we keep FIELD_EXTRACTION_ERROR unless the runtime behavior is intentionally changing too?
| content = msg.get("content", msg.get("message", {})) | ||
| if isinstance(content, dict): | ||
| # Unwrap nested content/message key | ||
| content = content.get("content", content.get("message", [])) |
There was a problem hiding this comment.
[P0] Could we reuse the existing CloudWatch message parsing here? Real Strands bodies encode user content as {"content": "[{\"text\": ...}]"}, and multi-turn input can contain interleaved user/assistant history. This helper returns the serialized JSON literally and selects the first historical message. Against three real invoke_agent events, it produced six turns but every user turn still contained JSON syntax. PR #454 also established the chronological-role/latest-user behavior, so sharing that parsing logic would keep this path consistent.
Background: PR #568
PR #568 (
feat: third-party eval metrics adapter (DeepEval + Autoevals) with strands-evals mappers) introduced theDeepEvalAdapter— a generic wrapper that takes any DeepEval metric and runs it inside an AgentCore Lambda evaluator. The adapter uses strands-evals mappers to auto-detect span formats, extract fields, and construct the appropriate DeepEval test case (LLMTestCasefor single-turn,ConversationalTestCasefor multi-turn).The Bug
The adapter's multi-turn extraction path relies on
_session_to_span_map_result()which builds theturnslist from multipleAgentInvocationSpanobjects — one per trace/invocation in the session. This works when the strands-evals mapper successfully parses spans into theSession → Trace → AgentInvocationSpanhierarchy (i.e., when each conversation turn arrives as a separate span in the CloudWatch split format).However, the AgentCore evaluation service normalizes spans before invoking Lambda at SESSION level. It collapses all ADOT span documents sharing the same
session.idinto a single span with multiplespan_eventsentries — eachspan_event.bodycontains one conversation turn'sinput.messagesandoutput.messages.In this service-normalized format:
Sessionwith only oneAgentInvocationSpan(because there's only one physical span)_session_to_span_map_result()producesturnswith only 2 entries (1 user + 1 assistant from the single AgentInvocationSpan)turns if len(turns) > 2 else Nonefilters this toNone_build_conversational_test_case()seesresult.turnsis empty and raises: "Multi-turn metric requires multiple conversation turns but only a single turn was found"The fallback
_extract_from_service_format()only handled single-turn extraction from gen_ai semantic convention events — it had no logic for parsingspan_events[*].body.Impact: Every multi-turn metric failed at SESSION level because no turns were extracted at all, forcing users to bypass the adapter and write custom conversational handlers.
The Fix
The fix extends
_extract_from_service_format()inregistry.pyto handle the service-normalized SESSION format:New helper
_extract_message_text()— parses the nested service message structure ({content: {content: [{text: ...}]}}and the{content: {message: [{text: ...}]}}variant) into plain text.Multi-turn extraction in
_extract_from_service_format()— added a new code path that runs first:span_eventswith ≥ 1 entryspan_event.body, extracts user input and assistant output frombody.input.messages/body.output.messagesturnslist (withturns=Nonewhen only 1 turn pair exists, matching existing semantics)SpanMapResultwith populatedturnsfieldTest expectation update — Two existing error-handling tests (
test_15_unrecognized_scope_deepeval,test_02_unrecognized_scopein autoevals) changed fromFIELD_EXTRACTION_ERRORtoMISSING_REQUIRED_FIELDbecause the mapper now succeeds in parsing the span (no extraction error) but finds empty input/output fields (triggering the more specific "missing required field" error).The fix is backward-compatible: the single-turn gen_ai events path remains unchanged as a fallback, and the multi-turn path only activates when
span_eventsentries are present.Scope
This PR restores turn extraction (
role+content) from the service-normalizedSESSION format. That is sufficient for the multi-turn metrics that only need the
conversation turns:
TopicAdherence, TurnRelevancy, ConversationalGEval
Out of Scope / Known Limitation (follow-up)
Metrics that need per-turn side data are NOT fixed by this PR, because the
service-normalized
span_events[*].bodyformat has no slot for that data — onlyinput.messagesandoutput.messages:_build_conversational_test_case()constructsTurn(role, content)only, so evenif the body carried these fields, they would be dropped. Fixing this requires a
format convention for where per-turn
retrieval_context/tools_calledlive inspan_events[*].body(e.g.body.retrieval_context,body.tools), then threadingthem through
SpanMapResult.turns[i]into theTurn(...)constructor. Tracked asa follow-up.
Testing
Added 9 new test cases:
test_span_mappers.py—TestServiceNormalizedMultiTurn(6 tests):{content: {content: [{text: ...}]}}varianttest_adapter.py—TestDeepEvalAdapterServiceNormalizedMultiTurn(3 tests):Note: RAG-context / tool-use multi-turn metrics are intentionally not covered
(see Out of Scope) — they require a format change, tracked separately.
Issue #, if available:
Description of changes:
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.