Skip to content

Commit 29b0115

Browse files
authored
feat: add ground truth support to EvaluationClient and OnDemandEvaluationDatasetRunner (#376)
Add ReferenceInputs (assertions, expected_trajectory, expected_response) and trace_id to EvaluationClient.run() for ground truth evaluation. Add OnDemandEvaluationDatasetRunner for orchestrating dataset-driven agent evaluation with parallel scenario execution, level-aware evaluator targeting (SESSION/TRACE/TOOL_CALL), and CloudWatch span collection. Make AgentSpanCollector public (previously _agent_span_collector).
1 parent bc95735 commit 29b0115

20 files changed

Lines changed: 2323 additions & 30 deletions

File tree

src/bedrock_agentcore/evaluation/__init__.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,35 @@
1-
"""AgentCore Evaluation: EvaluationClient and Strands integration."""
1+
"""AgentCore Evaluation: EvaluationClient, OnDemandEvaluationDatasetRunner, and Strands integration."""
22

3-
from bedrock_agentcore.evaluation.client import EvaluationClient
3+
from bedrock_agentcore.evaluation.client import EvaluationClient, ReferenceInputs
4+
from bedrock_agentcore.evaluation.runner.dataset_providers import (
5+
DatasetProvider,
6+
FileDatasetProvider,
7+
)
8+
from bedrock_agentcore.evaluation.runner.dataset_types import (
9+
Dataset,
10+
Input,
11+
PredefinedScenario,
12+
Scenario,
13+
Turn,
14+
)
15+
from bedrock_agentcore.evaluation.runner.invoker_types import (
16+
AgentInvokerFn,
17+
AgentInvokerInput,
18+
AgentInvokerOutput,
19+
)
20+
from bedrock_agentcore.evaluation.runner.on_demand import (
21+
AgentSpanCollector,
22+
CloudWatchAgentSpanCollector,
23+
EvaluationResult,
24+
EvaluationRunConfig,
25+
EvaluatorConfig,
26+
EvaluatorResult,
27+
OnDemandEvaluationDatasetRunner,
28+
PredefinedScenarioExecutor,
29+
ScenarioExecutionResult,
30+
ScenarioExecutor,
31+
ScenarioResult,
32+
)
433
from bedrock_agentcore.evaluation.span_to_adot_serializer import (
534
convert_strands_to_adot,
635
)
@@ -9,8 +38,30 @@
938
)
1039

1140
__all__ = [
41+
"AgentInvokerFn",
42+
"AgentInvokerInput",
43+
"AgentInvokerOutput",
44+
"CloudWatchAgentSpanCollector",
45+
"Dataset",
46+
"DatasetProvider",
1247
"EvaluationClient",
48+
"EvaluationResult",
49+
"EvaluationRunConfig",
50+
"OnDemandEvaluationDatasetRunner",
51+
"EvaluatorConfig",
52+
"EvaluatorResult",
53+
"FileDatasetProvider",
54+
"Input",
55+
"ReferenceInputs",
56+
"Scenario",
57+
"ScenarioExecutionResult",
58+
"ScenarioExecutor",
59+
"ScenarioResult",
60+
"AgentSpanCollector",
1361
"StrandsEvalsAgentCoreEvaluator",
62+
"Turn",
63+
"PredefinedScenario",
64+
"PredefinedScenarioExecutor",
1465
"convert_strands_to_adot",
1566
"create_strands_evaluator",
1667
"fetch_spans_from_cloudwatch",

src/bedrock_agentcore/evaluation/_agent_span_collector/__init__.py renamed to src/bedrock_agentcore/evaluation/agent_span_collector/__init__.py

File renamed without changes.

src/bedrock_agentcore/evaluation/_agent_span_collector/agent_span_collector.py renamed to src/bedrock_agentcore/evaluation/agent_span_collector/agent_span_collector.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ def _fetch_spans(self, session_id: str, start_time: datetime, end_time: datetime
122122
)
123123

124124
all_data = aws_spans + event_spans
125+
all_data.sort(key=lambda s: s.get("endTimeUnixNano", 0))
125126

126127
logger.info("Fetched %d span items from CloudWatch", len(all_data))
127128
return all_data

src/bedrock_agentcore/evaluation/client.py

Lines changed: 131 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22

33
import logging
44
from datetime import datetime, timedelta, timezone
5-
from typing import Any, Dict, List, Optional
5+
from typing import Any, Dict, List, Optional, Union
66

77
import boto3
88
from botocore.config import Config
9+
from pydantic import BaseModel
910

1011
from bedrock_agentcore._utils.user_agent import build_user_agent_suffix
11-
from bedrock_agentcore.evaluation._agent_span_collector import CloudWatchAgentSpanCollector
12+
from bedrock_agentcore.evaluation.agent_span_collector import CloudWatchAgentSpanCollector
1213

1314
logger = logging.getLogger(__name__)
1415

@@ -17,6 +18,21 @@
1718
POLL_INTERVAL_SECONDS = 2
1819

1920

21+
class ReferenceInputs(BaseModel):
22+
"""Ground truth inputs for evaluation.
23+
24+
Attributes:
25+
assertions: Natural language assertions about expected behavior (session-level).
26+
expected_trajectory: Expected tool names in order (session-level).
27+
expected_response: Expected response text. A plain string applies to the
28+
last trace. A ``{trace_id: response}`` dict targets specific traces.
29+
"""
30+
31+
assertions: Optional[List[str]] = None
32+
expected_trajectory: Optional[List[str]] = None
33+
expected_response: Optional[Union[str, Dict[str, str]]] = None
34+
35+
2036
class EvaluationClient:
2137
"""Client for evaluating agent sessions.
2238
@@ -83,6 +99,8 @@ def run(
8399
agent_id: Optional[str] = None,
84100
look_back_time: timedelta = timedelta(days=7),
85101
log_group_name: Optional[str] = None,
102+
trace_id: Optional[str] = None,
103+
reference_inputs: Optional[ReferenceInputs] = None,
86104
) -> List[Dict[str, Any]]:
87105
"""Evaluate an agent session end-to-end.
88106
@@ -104,6 +122,8 @@ def run(
104122
look_back_time: How far back to search for spans (default: 7 days).
105123
log_group_name: CloudWatch log group name. If provided, ``agent_id``
106124
is not required.
125+
trace_id: Optional trace ID to narrow evaluation to a single trace.
126+
reference_inputs: Optional ground truth for evaluation.
107127
108128
Returns:
109129
List of evaluation result dicts from all evaluators.
@@ -146,14 +166,23 @@ def run(
146166
logger.warning("No spans found for session %s", session_id)
147167
return []
148168

149-
base_input = {"evaluationInput": {"sessionSpans": spans}}
169+
base_input: Dict[str, Any] = {"evaluationInput": {"sessionSpans": spans}}
170+
171+
# Add reference inputs (ground truth) if provided
172+
if reference_inputs:
173+
all_trace_ids = self._extract_trace_ids(spans)
174+
ref_inputs = self._build_reference_inputs(
175+
session_id, reference_inputs, all_trace_ids, target_trace_id=trace_id
176+
)
177+
if ref_inputs:
178+
base_input["evaluationReferenceInputs"] = ref_inputs
150179

151180
# Steps 2-4: For each evaluator, look up level, build targets, call API
152-
all_results = []
181+
all_results: List[Dict[str, Any]] = []
153182
for evaluator_id in evaluator_ids:
154183
level = self._get_evaluator_level(evaluator_id)
155184
logger.info("Evaluating with %s (level=%s)", evaluator_id, level)
156-
requests = self._build_requests_for_level(evaluator_id, level, base_input, spans)
185+
requests = self._build_requests_for_level(evaluator_id, level, base_input, spans, trace_id)
157186
if len(requests) > 1:
158187
logger.debug("Split into %d batched request(s) for evaluator %s", len(requests), evaluator_id)
159188
for request in requests:
@@ -188,26 +217,36 @@ def _build_requests_for_level(
188217
level: str,
189218
base_input: dict,
190219
spans: list,
220+
trace_id: Optional[str] = None,
191221
) -> List[dict]:
192-
"""Build one or more evaluate request payloads based on evaluator level."""
222+
"""Build one or more evaluate request payloads based on evaluator level.
223+
224+
When ``trace_id`` is provided, TRACE-level evaluators target only that
225+
trace and TOOL_CALL-level evaluators are filtered to tool spans within
226+
that trace.
227+
"""
193228
if level == "SESSION":
194229
return [base_input]
195230

196231
if level == "TRACE":
232+
if trace_id:
233+
return [{**base_input, "evaluationTarget": {"traceIds": [trace_id]}}]
197234
trace_ids = self._extract_trace_ids(spans)
198235
logger.debug("Extracted %d unique trace ID(s) for evaluator %s", len(trace_ids), evaluator_id)
199236
if not trace_ids:
200-
raise ValueError(f"No trace IDs found for trace-level evaluator {evaluator_id}")
237+
logger.warning("No trace IDs found for trace-level evaluator %s, skipping", evaluator_id)
238+
return []
201239
return [
202240
{**base_input, "evaluationTarget": {"traceIds": trace_ids[i : i + MAX_TARGET_IDS_PER_REQUEST]}}
203241
for i in range(0, len(trace_ids), MAX_TARGET_IDS_PER_REQUEST)
204242
]
205243

206244
if level == "TOOL_CALL":
207-
tool_span_ids = self._extract_tool_span_ids(spans)
245+
tool_span_ids = self._extract_tool_span_ids(spans, trace_id=trace_id)
208246
logger.debug("Extracted %d tool span ID(s) for evaluator %s", len(tool_span_ids), evaluator_id)
209247
if not tool_span_ids:
210-
raise ValueError(f"No tool span IDs found for tool-level evaluator {evaluator_id}")
248+
logger.warning("No tool span IDs found for tool-level evaluator %s, skipping", evaluator_id)
249+
return []
211250
return [
212251
{**base_input, "evaluationTarget": {"spanIds": tool_span_ids[i : i + MAX_TARGET_IDS_PER_REQUEST]}}
213252
for i in range(0, len(tool_span_ids), MAX_TARGET_IDS_PER_REQUEST)
@@ -221,14 +260,92 @@ def _extract_trace_ids(spans: list) -> List[str]:
221260
return list(dict.fromkeys(span.get("traceId") for span in spans if span.get("traceId")))
222261

223262
@staticmethod
224-
def _extract_tool_span_ids(spans: list) -> List[str]:
225-
"""Extract span IDs for tool execution spans."""
263+
def _is_tool_span(span: dict) -> bool:
264+
"""Check if a span represents a tool execution (supports Strands, LangGraph, and Traceloop)."""
265+
attrs = span.get("attributes", {})
266+
if not isinstance(attrs, dict):
267+
return False
268+
return (
269+
attrs.get("gen_ai.operation.name") == "execute_tool"
270+
or attrs.get("openinference.span.kind") == "TOOL"
271+
or attrs.get("traceloop.span.kind") == "tool"
272+
)
273+
274+
@staticmethod
275+
def _extract_tool_span_ids(spans: list, trace_id: Optional[str] = None) -> List[str]:
276+
"""Extract span IDs for tool execution spans.
277+
278+
Args:
279+
spans: List of span dicts.
280+
trace_id: If provided, only include tool spans with this trace ID.
281+
"""
226282
tool_span_ids: List[str] = []
227283
for span in spans:
228-
name = span.get("name", "")
229-
kind = span.get("kind")
230-
if kind == "SPAN_KIND_INTERNAL" and name.startswith("Tool:"):
284+
if EvaluationClient._is_tool_span(span):
285+
if trace_id and span.get("traceId") != trace_id:
286+
continue
231287
span_id = span.get("spanId")
232288
if span_id:
233289
tool_span_ids.append(span_id)
234290
return tool_span_ids
291+
292+
@staticmethod
293+
def _build_reference_inputs(
294+
session_id: str,
295+
reference_inputs: "ReferenceInputs",
296+
trace_ids: List[str],
297+
target_trace_id: Optional[str] = None,
298+
) -> List[Dict[str, Any]]:
299+
"""Build evaluationReferenceInputs from ReferenceInputs.
300+
301+
Returns a list of reference input dicts scoped by spanContext:
302+
- Session-level entry for assertions and/or expected_trajectory.
303+
- Per-trace entries for expected_response.
304+
305+
Args:
306+
session_id: The session ID for span context.
307+
reference_inputs: Ground truth inputs for evaluation.
308+
trace_ids: All trace IDs extracted from spans.
309+
target_trace_id: When provided and expected_response is a string,
310+
targets this trace instead of the last trace.
311+
"""
312+
result: List[Dict[str, Any]] = []
313+
314+
# Session-level: assertions and/or expected_trajectory
315+
session_ref: Dict[str, Any] = {"context": {"spanContext": {"sessionId": session_id}}}
316+
has_session_ref = False
317+
318+
if reference_inputs.assertions:
319+
session_ref["assertions"] = [{"text": a} for a in reference_inputs.assertions]
320+
has_session_ref = True
321+
322+
if reference_inputs.expected_trajectory:
323+
session_ref["expectedTrajectory"] = {"toolNames": reference_inputs.expected_trajectory}
324+
has_session_ref = True
325+
326+
if has_session_ref:
327+
result.append(session_ref)
328+
329+
# Trace-level: expected_response
330+
if reference_inputs.expected_response is not None:
331+
if isinstance(reference_inputs.expected_response, str):
332+
# Use explicit target_trace_id if provided, otherwise fall back to the last trace
333+
resolved_trace_id = target_trace_id if target_trace_id else (trace_ids[-1] if trace_ids else None)
334+
if resolved_trace_id:
335+
result.append(
336+
{
337+
"context": {"spanContext": {"sessionId": session_id, "traceId": resolved_trace_id}},
338+
"expectedResponse": {"text": reference_inputs.expected_response},
339+
}
340+
)
341+
elif isinstance(reference_inputs.expected_response, dict):
342+
# Dict maps trace_id -> response
343+
for tid, response_text in reference_inputs.expected_response.items():
344+
result.append(
345+
{
346+
"context": {"spanContext": {"sessionId": session_id, "traceId": tid}},
347+
"expectedResponse": {"text": response_text},
348+
}
349+
)
350+
351+
return result
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Runner package: shared types and evaluation runner."""
2+
3+
from .dataset_providers import DatasetProvider, FileDatasetProvider
4+
from .dataset_types import (
5+
Dataset,
6+
Input,
7+
PredefinedScenario,
8+
Scenario,
9+
Turn,
10+
)
11+
from .invoker_types import (
12+
AgentInvokerFn,
13+
AgentInvokerInput,
14+
AgentInvokerOutput,
15+
)
16+
from .on_demand import (
17+
AgentSpanCollector,
18+
CloudWatchAgentSpanCollector,
19+
EvaluationResult,
20+
EvaluationRunConfig,
21+
EvaluatorConfig,
22+
EvaluatorResult,
23+
OnDemandEvaluationDatasetRunner,
24+
ScenarioResult,
25+
)
26+
from .scenario_executor import (
27+
PredefinedScenarioExecutor,
28+
ScenarioExecutionResult,
29+
ScenarioExecutor,
30+
)
31+
32+
__all__ = [
33+
"AgentInvokerFn",
34+
"AgentInvokerInput",
35+
"AgentInvokerOutput",
36+
"CloudWatchAgentSpanCollector",
37+
"Dataset",
38+
"DatasetProvider",
39+
"EvaluationResult",
40+
"EvaluationRunConfig",
41+
"OnDemandEvaluationDatasetRunner",
42+
"EvaluatorConfig",
43+
"EvaluatorResult",
44+
"FileDatasetProvider",
45+
"Input",
46+
"Scenario",
47+
"ScenarioExecutionResult",
48+
"ScenarioExecutor",
49+
"ScenarioResult",
50+
"AgentSpanCollector",
51+
"Turn",
52+
"PredefinedScenario",
53+
"PredefinedScenarioExecutor",
54+
]

0 commit comments

Comments
 (0)