22
33import logging
44from datetime import datetime , timedelta , timezone
5- from typing import Any , Dict , List , Optional
5+ from typing import Any , Dict , List , Optional , Union
66
77import boto3
88from botocore .config import Config
9+ from pydantic import BaseModel
910
1011from 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
1314logger = logging .getLogger (__name__ )
1415
1718POLL_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+
2036class 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
0 commit comments