Skip to content

Commit 102ba0d

Browse files
authored
feat: add EvaluationClient with run() for on-demand session evaluation (aws#300)
* feat: add EvaluationClient with run() for on-demand session evaluation EvaluationClient collects spans from CloudWatch and calls the evaluate API with level-aware batching (SESSION/TRACE/TOOL_CALL). Accepts evaluator_ids, session_id, and agent_id or log_group_name. Auto-derives log group from agent_id, caches evaluator level lookups, and batches evaluate requests at max 10 target IDs per request. * refactor: simplify EvaluationClient and let exceptions propagate Remove try/except around evaluate() so errors propagate to the caller instead of being silently swallowed. Simplify _extract_trace_ids with dict.fromkeys(), inline _batch() into list comprehensions, and remove the evaluator_result_count tracking variable. * style: fix trailing newlines from ruff format
1 parent 328acba commit 102ba0d

8 files changed

Lines changed: 949 additions & 21 deletions

File tree

src/bedrock_agentcore/evaluation/__init__.py

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

3+
from bedrock_agentcore.evaluation.client import EvaluationClient
34
from bedrock_agentcore.evaluation.integrations.strands_agents_evals.evaluator import (
45
StrandsEvalsAgentCoreEvaluator,
56
create_strands_evaluator,
@@ -12,8 +13,9 @@
1213
)
1314

1415
__all__ = [
15-
"create_strands_evaluator",
16+
"EvaluationClient",
1617
"StrandsEvalsAgentCoreEvaluator",
1718
"convert_strands_to_adot",
19+
"create_strands_evaluator",
1820
"fetch_spans_from_cloudwatch",
1921
]
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Agent span collector: collects OpenTelemetry spans for evaluation."""
2+
3+
from .agent_span_collector import AgentSpanCollector, CloudWatchAgentSpanCollector
4+
5+
__all__ = [
6+
"AgentSpanCollector",
7+
"CloudWatchAgentSpanCollector",
8+
]
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Span collector abstraction for the evaluation runner."""
2+
3+
import logging
4+
import time
5+
from abc import ABC, abstractmethod
6+
from datetime import datetime, timedelta
7+
from typing import List
8+
9+
from bedrock_agentcore._utils.endpoints import DEFAULT_REGION
10+
from bedrock_agentcore.evaluation.utils.cloudwatch_span_helper import CloudWatchSpanHelper
11+
12+
logger = logging.getLogger(__name__)
13+
14+
AWS_SPANS_LOG_GROUP = "aws/spans"
15+
16+
17+
class AgentSpanCollector(ABC):
18+
"""Abstract base class for collecting spans after agent invocation."""
19+
20+
@abstractmethod
21+
def collect(self, session_id: str, start_time: datetime, end_time: datetime) -> List[dict]:
22+
"""Collect spans for a given session.
23+
24+
Args:
25+
session_id: The session ID to collect spans for.
26+
start_time: The start time of the session invocation.
27+
end_time: The end time of the session invocation.
28+
29+
Returns:
30+
List of span dictionaries.
31+
"""
32+
33+
34+
class CloudWatchAgentSpanCollector(AgentSpanCollector):
35+
"""Collects spans from CloudWatch using precise attributes.session.id filtering."""
36+
37+
def __init__(
38+
self,
39+
log_group_name: str,
40+
region: str = DEFAULT_REGION,
41+
max_wait_seconds: int = 300,
42+
poll_interval_seconds: int = 30,
43+
):
44+
"""Initialize the CloudWatch span collector.
45+
46+
Args:
47+
log_group_name: CloudWatch log group name for event logs.
48+
region: AWS region for CloudWatch client.
49+
max_wait_seconds: Maximum time to poll for spans before giving up (default 300s).
50+
poll_interval_seconds: Time between poll attempts (default 30s).
51+
"""
52+
self.log_group_name = log_group_name
53+
self.region = region
54+
self.max_wait_seconds = max_wait_seconds
55+
self.poll_interval_seconds = poll_interval_seconds
56+
self._helper = CloudWatchSpanHelper(region=region)
57+
58+
def collect(self, session_id: str, start_time: datetime, end_time: datetime) -> List[dict]:
59+
"""Collect spans from CloudWatch, polling until spans appear or timeout.
60+
61+
Args:
62+
session_id: The session ID to collect spans for.
63+
start_time: The start time of the session invocation.
64+
end_time: The end time of the session invocation.
65+
66+
Returns:
67+
List of ADOT span dictionaries.
68+
"""
69+
# Widen the query window so spans ingested shortly after the
70+
# invocation ended are not excluded. CloudWatch Logs Insights
71+
# treats endTime as exclusive and ingestion can lag by seconds,
72+
# so a 60-second buffer avoids missing spans on every retry.
73+
query_end_time = end_time + timedelta(seconds=60)
74+
logger.debug(
75+
"Collecting spans for session_id=%s, log_group=%s, time_range=[%s, %s]",
76+
session_id,
77+
self.log_group_name,
78+
start_time,
79+
query_end_time,
80+
)
81+
deadline = time.monotonic() + self.max_wait_seconds
82+
83+
while True:
84+
spans = self._fetch_spans(session_id, start_time, query_end_time)
85+
logger.debug("fetch_spans returned %d span(s)", len(spans))
86+
87+
if spans:
88+
logger.info("Collected %d span(s) for session %s", len(spans), session_id)
89+
return spans
90+
91+
if time.monotonic() + self.poll_interval_seconds > deadline:
92+
logger.warning(
93+
"Span collection timed out after %ds for session %s (0 spans found)",
94+
self.max_wait_seconds,
95+
session_id,
96+
)
97+
return spans
98+
99+
logger.info("No spans found yet, retrying in %ds...", self.poll_interval_seconds)
100+
time.sleep(self.poll_interval_seconds)
101+
102+
def _fetch_spans(self, session_id: str, start_time: datetime, end_time: datetime) -> List[dict]:
103+
"""Fetch spans from both aws/spans and the configured log group.
104+
105+
Queries both log groups with a precise attributes.session.id filter,
106+
combines results, and returns only valid ADOT span documents.
107+
"""
108+
query_string = (
109+
f"fields @timestamp, @message"
110+
f'\n| filter attributes.session.id = "{session_id}"'
111+
f"\n| filter ispresent(scope.name)"
112+
f"\n| filter ispresent(traceId)"
113+
f"\n| filter ispresent(spanId)"
114+
f"\n| sort @timestamp asc"
115+
)
116+
117+
aws_spans = self._helper.query_log_group(
118+
AWS_SPANS_LOG_GROUP, session_id, start_time, end_time, query_string=query_string
119+
)
120+
event_spans = self._helper.query_log_group(
121+
self.log_group_name, session_id, start_time, end_time, query_string=query_string
122+
)
123+
124+
all_data = aws_spans + event_spans
125+
126+
logger.info("Fetched %d span items from CloudWatch", len(all_data))
127+
return all_data
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
"""EvaluationClient for collecting spans and running evaluations."""
2+
3+
import logging
4+
from datetime import datetime, timedelta, timezone
5+
from typing import Any, Dict, List, Optional
6+
7+
import boto3
8+
from botocore.config import Config
9+
10+
from bedrock_agentcore._utils.user_agent import build_user_agent_suffix
11+
from bedrock_agentcore.evaluation._agent_span_collector import CloudWatchAgentSpanCollector
12+
13+
logger = logging.getLogger(__name__)
14+
15+
MAX_TARGET_IDS_PER_REQUEST = 10
16+
QUERY_TIMEOUT_SECONDS = 60
17+
POLL_INTERVAL_SECONDS = 2
18+
19+
20+
class EvaluationClient:
21+
"""Client for evaluating agent sessions.
22+
23+
Collects spans from CloudWatch and calls the evaluation API with
24+
level-aware batching.
25+
26+
Example::
27+
28+
client = EvaluationClient(region_name="us-west-2")
29+
30+
# Using agent_id (log group derived automatically)
31+
results = client.run(
32+
evaluator_ids=["accuracy", "toxicity"],
33+
session_id="sess-123",
34+
agent_id="my-agent",
35+
)
36+
37+
# Using log_group_name directly
38+
results = client.run(
39+
evaluator_ids=["accuracy", "toxicity"],
40+
session_id="sess-123",
41+
log_group_name="/custom/my-log-group",
42+
)
43+
44+
for r in results:
45+
print(f"{r['evaluatorId']}: {r.get('value')} - {r.get('explanation')}")
46+
"""
47+
48+
def __init__(
49+
self,
50+
region_name: Optional[str] = None,
51+
integration_source: Optional[str] = None,
52+
):
53+
"""Initialize the EvaluationClient.
54+
55+
Args:
56+
region_name: AWS region. Falls back to boto3 session region or us-west-2.
57+
integration_source: Optional integration framework identifier for telemetry.
58+
"""
59+
self.region_name = region_name or boto3.Session().region_name or "us-west-2"
60+
self.integration_source = integration_source
61+
62+
user_agent_extra = build_user_agent_suffix(integration_source)
63+
client_config = Config(user_agent_extra=user_agent_extra)
64+
65+
self._dp_client = boto3.client(
66+
"bedrock-agentcore",
67+
region_name=self.region_name,
68+
config=client_config,
69+
)
70+
self._cp_client = boto3.client(
71+
"bedrock-agentcore-control",
72+
region_name=self.region_name,
73+
config=client_config,
74+
)
75+
self._evaluator_level_cache: Dict[str, str] = {}
76+
77+
logger.info("Initialized EvaluationClient in region %s", self.region_name)
78+
79+
def run(
80+
self,
81+
evaluator_ids: List[str],
82+
session_id: str,
83+
agent_id: Optional[str] = None,
84+
look_back_time: timedelta = timedelta(days=7),
85+
log_group_name: Optional[str] = None,
86+
) -> List[Dict[str, Any]]:
87+
"""Evaluate an agent session end-to-end.
88+
89+
1. Collects spans from CloudWatch.
90+
2. For each evaluator, looks up its level (SESSION/TRACE/TOOL_CALL).
91+
3. Builds the appropriate evaluationTarget based on level.
92+
4. Calls evaluate() with auto-batching (max 10 target IDs per request).
93+
5. Returns combined evaluationResults from all evaluators.
94+
95+
Either ``agent_id`` or ``log_group_name`` must be provided.
96+
When only ``agent_id`` is given, the log group name is derived as
97+
``/aws/bedrock-agentcore/runtimes/{agent_id}-DEFAULT``.
98+
99+
Args:
100+
evaluator_ids: List of evaluator IDs (built-in or custom ARNs).
101+
session_id: The session ID to evaluate.
102+
agent_id: The agent ID. Used to derive the log group when
103+
``log_group_name`` is not provided.
104+
look_back_time: How far back to search for spans (default: 7 days).
105+
log_group_name: CloudWatch log group name. If provided, ``agent_id``
106+
is not required.
107+
108+
Returns:
109+
List of evaluation result dicts from all evaluators.
110+
111+
Raises:
112+
ValueError: If neither ``agent_id`` nor ``log_group_name`` is provided.
113+
"""
114+
if not agent_id and not log_group_name:
115+
raise ValueError("Provide either agent_id or log_group_name.")
116+
117+
if not log_group_name:
118+
log_group_name = f"/aws/bedrock-agentcore/runtimes/{agent_id}-DEFAULT"
119+
logger.debug("Derived log_group_name=%s from agent_id=%s", log_group_name, agent_id)
120+
121+
end_time = datetime.now(timezone.utc)
122+
start_time = end_time - look_back_time
123+
124+
logger.info(
125+
"Running evaluation for session=%s, log_group=%s, time_range=[%s, %s]",
126+
session_id,
127+
log_group_name,
128+
start_time,
129+
end_time,
130+
)
131+
132+
# Step 1: Collect spans
133+
collector = CloudWatchAgentSpanCollector(
134+
log_group_name=log_group_name,
135+
region=self.region_name,
136+
max_wait_seconds=QUERY_TIMEOUT_SECONDS,
137+
poll_interval_seconds=POLL_INTERVAL_SECONDS,
138+
)
139+
spans = collector.collect(
140+
session_id=session_id,
141+
start_time=start_time,
142+
end_time=end_time,
143+
)
144+
145+
if not spans:
146+
logger.warning("No spans found for session %s", session_id)
147+
return []
148+
149+
base_input = {"evaluationInput": {"sessionSpans": spans}}
150+
151+
# Steps 2-4: For each evaluator, look up level, build targets, call API
152+
all_results = []
153+
for evaluator_id in evaluator_ids:
154+
level = self._get_evaluator_level(evaluator_id)
155+
logger.info("Evaluating with %s (level=%s)", evaluator_id, level)
156+
requests = self._build_requests_for_level(evaluator_id, level, base_input, spans)
157+
if len(requests) > 1:
158+
logger.debug("Split into %d batched request(s) for evaluator %s", len(requests), evaluator_id)
159+
for request in requests:
160+
response = self._dp_client.evaluate(evaluatorId=evaluator_id, **request)
161+
all_results.extend(response.get("evaluationResults", []))
162+
163+
logger.info(
164+
"Evaluation complete: %d result(s) from %d evaluator(s)",
165+
len(all_results),
166+
len(evaluator_ids),
167+
)
168+
return all_results
169+
170+
def _get_evaluator_level(self, evaluator_id: str) -> str:
171+
"""Look up evaluator level with caching. Falls back to SESSION."""
172+
if evaluator_id not in self._evaluator_level_cache:
173+
try:
174+
response = self._cp_client.get_evaluator(evaluatorId=evaluator_id)
175+
self._evaluator_level_cache[evaluator_id] = response["level"]
176+
except Exception as e:
177+
logger.warning(
178+
"Failed to get level for %s, defaulting to SESSION: %s",
179+
evaluator_id,
180+
e,
181+
)
182+
self._evaluator_level_cache[evaluator_id] = "SESSION"
183+
return self._evaluator_level_cache[evaluator_id]
184+
185+
def _build_requests_for_level(
186+
self,
187+
evaluator_id: str,
188+
level: str,
189+
base_input: dict,
190+
spans: list,
191+
) -> List[dict]:
192+
"""Build one or more evaluate request payloads based on evaluator level."""
193+
if level == "SESSION":
194+
return [base_input]
195+
196+
if level == "TRACE":
197+
trace_ids = self._extract_trace_ids(spans)
198+
logger.debug("Extracted %d unique trace ID(s) for evaluator %s", len(trace_ids), evaluator_id)
199+
if not trace_ids:
200+
raise ValueError(f"No trace IDs found for trace-level evaluator {evaluator_id}")
201+
return [
202+
{**base_input, "evaluationTarget": {"traceIds": trace_ids[i : i + MAX_TARGET_IDS_PER_REQUEST]}}
203+
for i in range(0, len(trace_ids), MAX_TARGET_IDS_PER_REQUEST)
204+
]
205+
206+
if level == "TOOL_CALL":
207+
tool_span_ids = self._extract_tool_span_ids(spans)
208+
logger.debug("Extracted %d tool span ID(s) for evaluator %s", len(tool_span_ids), evaluator_id)
209+
if not tool_span_ids:
210+
raise ValueError(f"No tool span IDs found for tool-level evaluator {evaluator_id}")
211+
return [
212+
{**base_input, "evaluationTarget": {"spanIds": tool_span_ids[i : i + MAX_TARGET_IDS_PER_REQUEST]}}
213+
for i in range(0, len(tool_span_ids), MAX_TARGET_IDS_PER_REQUEST)
214+
]
215+
216+
raise ValueError(f"Unknown evaluator level: {level}")
217+
218+
@staticmethod
219+
def _extract_trace_ids(spans: list) -> List[str]:
220+
"""Extract unique trace IDs from spans, ordered by appearance."""
221+
return list(dict.fromkeys(span.get("traceId") for span in spans if span.get("traceId")))
222+
223+
@staticmethod
224+
def _extract_tool_span_ids(spans: list) -> List[str]:
225+
"""Extract span IDs for tool execution spans."""
226+
tool_span_ids: List[str] = []
227+
for span in spans:
228+
name = span.get("name", "")
229+
kind = span.get("kind")
230+
if kind == "SPAN_KIND_INTERNAL" and name.startswith("Tool:"):
231+
span_id = span.get("spanId")
232+
if span_id:
233+
tool_span_ids.append(span_id)
234+
return tool_span_ids

0 commit comments

Comments
 (0)