Skip to content

Commit fd7d15d

Browse files
sundargthbSundar Raghavan
andauthored
feat(evaluation): evaluate existing sessions and add RecommendationClient (#708)
* feat(evaluation): evaluate existing sessions and add RecommendationClient Add BatchEvaluationRunner.run_session_evaluation to score sessions already in CloudWatch without invoking the agent, and RecommendationClient with start_recommendation_and_wait for system prompt and tool description recommendations. * refactor(evaluation): move existing-session runs to EvaluationClient Allowlist the batch evaluation APIs on EvaluationClient and add start_batch_evaluation_and_wait. Revert run_session_evaluation from BatchEvaluationRunner, which is for dataset runs. wait_until accepts a set of target statuses. --------- Co-authored-by: Sundar Raghavan <sdraghav@amazon.com>
1 parent 147c5b3 commit fd7d15d

8 files changed

Lines changed: 384 additions & 13 deletions

File tree

‎src/bedrock_agentcore/_utils/polling.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import logging
44
import time
5-
from typing import Any, Callable, Dict, Optional, Set
5+
from typing import Any, Callable, Dict, Optional, Set, Union
66

77
from .config import WaitConfig
88

@@ -11,7 +11,7 @@
1111

1212
def wait_until(
1313
poll_fn: Callable[[], Dict[str, Any]],
14-
target: str,
14+
target: Union[str, Set[str]],
1515
failed: Set[str],
1616
wait_config: Optional[WaitConfig] = None,
1717
error_field: str = "statusReasons",
@@ -20,7 +20,7 @@ def wait_until(
2020
2121
Args:
2222
poll_fn: Zero-arg callable that returns the resource's current state.
23-
target: The status to wait for (e.g. "ACTIVE", "READY").
23+
target: The status to wait for (e.g. "ACTIVE", "READY"), or a set of them.
2424
failed: Statuses that indicate terminal failure.
2525
wait_config: Optional WaitConfig for polling behavior.
2626
error_field: Response field containing error details.
@@ -33,13 +33,14 @@ def wait_until(
3333
TimeoutError: If target status is not reached within max_wait.
3434
"""
3535
wait = wait_config or WaitConfig()
36+
targets = {target} if isinstance(target, str) else target
3637
start_time = time.time()
3738
while True:
3839
resp = poll_fn()
3940
status = resp.get("status")
4041
if status is None:
4142
logger.warning("Response missing 'status' field: %s", resp)
42-
if status == target:
43+
if status in targets:
4344
return resp
4445
if status in failed:
4546
reason = resp.get(error_field, "Unknown")

‎src/bedrock_agentcore/evaluation/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
custom_code_based_evaluator,
1010
)
1111
from bedrock_agentcore.evaluation.dataset_client import DatasetClient
12+
from bedrock_agentcore.evaluation.recommendation_client import RecommendationClient
1213
from bedrock_agentcore.evaluation.runner.batch.batch_evaluation_models import (
1314
BatchEvaluationResult,
1415
BatchEvaluationRunConfig,
@@ -94,6 +95,7 @@
9495
"CloudWatchAgentSpanCollector",
9596
"Dataset",
9697
"DatasetClient",
98+
"RecommendationClient",
9799
"DatasetProvider",
98100
"EvaluationClient",
99101
"EvaluationResult",

‎src/bedrock_agentcore/evaluation/client.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
POLL_INTERVAL_SECONDS = 2
2323

2424
_EVALUATOR_FAILED_STATUSES = {"CREATE_FAILED", "UPDATE_FAILED"}
25+
_BATCH_EVALUATION_DONE_STATUSES = {"COMPLETED", "COMPLETED_WITH_ERRORS"}
26+
_BATCH_EVALUATION_FAILED_STATUSES = {"FAILED", "STOPPED"}
2527

2628

2729
class ReferenceInputs(BaseModel):
@@ -102,6 +104,12 @@ def __init__(
102104
# -------------------------------------------------------------------------
103105
_ALLOWED_DP_METHODS = {
104106
"evaluate",
107+
# Batch evaluation
108+
"start_batch_evaluation",
109+
"get_batch_evaluation",
110+
"list_batch_evaluations",
111+
"stop_batch_evaluation",
112+
"delete_batch_evaluation",
105113
}
106114

107115
_ALLOWED_CP_METHODS = {
@@ -372,6 +380,41 @@ def _build_reference_inputs(
372380

373381
# *_and_wait methods
374382
# -------------------------------------------------------------------------
383+
def start_batch_evaluation_and_wait(
384+
self,
385+
wait_config: Optional[WaitConfig] = None,
386+
**kwargs,
387+
) -> Dict[str, Any]:
388+
"""Start a batch evaluation and wait for it to finish.
389+
390+
Scores sessions that already exist in CloudWatch, without invoking the
391+
agent. To run a dataset against an agent, use ``BatchEvaluationRunner``.
392+
Batch evaluations typically take several minutes, so pass a ``max_wait``
393+
longer than the default 5 minutes.
394+
395+
Args:
396+
wait_config: Optional WaitConfig for polling behavior.
397+
**kwargs: Arguments forwarded to the start_batch_evaluation API.
398+
399+
Returns:
400+
The GetBatchEvaluation response when COMPLETED or COMPLETED_WITH_ERRORS.
401+
Scores are under ``evaluationResults``, and insights from analysis
402+
evaluators such as ``Builtin.FailureAnalysis`` under ``insightResults``.
403+
404+
Raises:
405+
RuntimeError: If the batch evaluation reaches FAILED or STOPPED status.
406+
TimeoutError: If the batch evaluation doesn't finish within max_wait.
407+
"""
408+
response = self._dp_client.start_batch_evaluation(**convert_kwargs(kwargs))
409+
batch_id = response["batchEvaluationId"]
410+
return wait_until(
411+
lambda: self._dp_client.get_batch_evaluation(batchEvaluationId=batch_id),
412+
_BATCH_EVALUATION_DONE_STATUSES,
413+
_BATCH_EVALUATION_FAILED_STATUSES,
414+
wait_config,
415+
error_field="errorDetails",
416+
)
417+
375418
def create_evaluator_and_wait(
376419
self,
377420
wait_config: Optional[WaitConfig] = None,
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""RecommendationClient for AgentCore system prompt and tool description recommendations."""
2+
3+
import logging
4+
from typing import Any, Dict, Optional
5+
6+
import boto3
7+
from botocore.config import Config
8+
9+
from bedrock_agentcore._utils.config import WaitConfig
10+
from bedrock_agentcore._utils.endpoints import DP_ENDPOINT_OVERRIDE
11+
from bedrock_agentcore._utils.polling import wait_until
12+
from bedrock_agentcore._utils.snake_case import accept_snake_case_kwargs, convert_kwargs
13+
from bedrock_agentcore._utils.user_agent import build_user_agent_suffix
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class RecommendationClient:
19+
"""Client for AgentCore recommendations.
20+
21+
Provides pass-through access to the recommendation APIs on the
22+
bedrock-agentcore service, plus ``start_recommendation_and_wait`` for the
23+
asynchronous start-then-poll flow every recommendation needs.
24+
25+
.. warning::
26+
This feature is in preview and may change in future releases.
27+
28+
Example::
29+
30+
client = RecommendationClient(region_name="us-west-2")
31+
32+
recommendation = client.start_recommendation_and_wait(
33+
name="my-prompt-rec",
34+
type="SYSTEM_PROMPT_RECOMMENDATION",
35+
recommendationConfig={"systemPromptRecommendationConfig": {
36+
"systemPrompt": {"text": "You are a helpful assistant."},
37+
"agentTraces": {"batchEvaluation": {"batchEvaluationArn": arn}},
38+
"evaluationConfig": {"evaluators": [
39+
{"evaluatorArn": "arn:aws:bedrock-agentcore:::evaluator/Builtin.GoalSuccessRate"}
40+
]},
41+
}},
42+
)
43+
print(recommendation["recommendationResult"])
44+
45+
# Pass-through to any recommendation API
46+
client.list_recommendations(maxResults=10)
47+
"""
48+
49+
_ALLOWED_DP_METHODS = {
50+
"start_recommendation",
51+
"get_recommendation",
52+
"list_recommendations",
53+
"delete_recommendation",
54+
}
55+
56+
def __init__(
57+
self,
58+
region_name: Optional[str] = None,
59+
integration_source: Optional[str] = None,
60+
boto3_session: Optional[boto3.Session] = None,
61+
):
62+
"""Initialize the RecommendationClient.
63+
64+
Args:
65+
region_name: AWS region. Falls back to boto3 session region or us-west-2.
66+
integration_source: Optional integration framework identifier for telemetry.
67+
boto3_session: Optional boto3 Session. If not provided, a default is created.
68+
"""
69+
session = boto3_session if boto3_session else boto3.Session()
70+
self.region_name = region_name or session.region_name or "us-west-2"
71+
self.integration_source = integration_source
72+
73+
client_kwargs: Dict[str, Any] = {
74+
"region_name": self.region_name,
75+
"config": Config(user_agent_extra=build_user_agent_suffix(integration_source)),
76+
}
77+
if DP_ENDPOINT_OVERRIDE:
78+
client_kwargs["endpoint_url"] = DP_ENDPOINT_OVERRIDE
79+
self._dp_client = session.client("bedrock-agentcore", **client_kwargs)
80+
81+
logger.info("Initialized RecommendationClient in region %s", self.region_name)
82+
83+
def __getattr__(self, name: str):
84+
"""Dynamically forward allowlisted method calls to the boto3 client."""
85+
if "_dp_client" not in self.__dict__:
86+
raise AttributeError(name)
87+
88+
if name in self._ALLOWED_DP_METHODS and hasattr(self._dp_client, name):
89+
method = getattr(self._dp_client, name)
90+
logger.debug("Forwarding method '%s' to _dp_client", name)
91+
return accept_snake_case_kwargs(method)
92+
93+
raise AttributeError(
94+
f"'{self.__class__.__name__}' object has no attribute '{name}'. "
95+
f"Method not found on data plane client. "
96+
f"Available methods can be found in the boto3 documentation for "
97+
f"'bedrock-agentcore' service."
98+
)
99+
100+
def start_recommendation_and_wait(
101+
self,
102+
wait_config: Optional[WaitConfig] = None,
103+
**kwargs,
104+
) -> Dict[str, Any]:
105+
"""Start a recommendation and wait for it to reach COMPLETED status.
106+
107+
Recommendations typically take several minutes, so the default
108+
``WaitConfig`` (5 minutes) may be too short. Pass a longer ``max_wait``
109+
for large trace sets.
110+
111+
Args:
112+
wait_config: Optional WaitConfig for polling behavior.
113+
**kwargs: Arguments forwarded to the start_recommendation API.
114+
115+
Returns:
116+
The GetRecommendation response when COMPLETED. The generated result is
117+
under ``recommendationResult``. Note that ``recommendationConfig``
118+
echoes the input you submitted, so read the result from
119+
``recommendationResult`` rather than searching the response.
120+
121+
Raises:
122+
RuntimeError: If the recommendation reaches FAILED status.
123+
TimeoutError: If the recommendation doesn't complete within max_wait.
124+
"""
125+
response = self._dp_client.start_recommendation(**convert_kwargs(kwargs))
126+
recommendation_id = response["recommendationId"]
127+
logger.info("Started recommendation %s", recommendation_id)
128+
return wait_until(
129+
lambda: self._dp_client.get_recommendation(recommendationId=recommendation_id),
130+
"COMPLETED",
131+
{"FAILED"},
132+
wait_config,
133+
error_field="recommendationResult",
134+
)

‎src/bedrock_agentcore/evaluation/runner/batch/batch_evaluation_models.py‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,18 +95,19 @@ def to_data_source_config(
9595
start_time: datetime,
9696
end_time: datetime,
9797
) -> Dict[str, Any]:
98-
"""Return a cloudWatchLogs dataSourceConfig dict for the evaluation API."""
98+
"""Return a cloudWatchLogs dataSourceConfig dict for the evaluation API.
99+
100+
``sessionIds`` is omitted when empty, so that every session in the time
101+
range is evaluated.
102+
"""
103+
filter_config: Dict[str, Any] = {"timeRange": {"startTime": start_time, "endTime": end_time}}
104+
if session_ids:
105+
filter_config["sessionIds"] = session_ids
99106
return {
100107
"cloudWatchLogs": {
101108
"serviceNames": self.service_names,
102109
"logGroupNames": self.log_group_names,
103-
"filterConfig": {
104-
"sessionIds": session_ids,
105-
"timeRange": {
106-
"startTime": start_time,
107-
"endTime": end_time,
108-
},
109-
},
110+
"filterConfig": filter_config,
110111
}
111112
}
112113

‎tests/bedrock_agentcore/evaluation/test_client_passthrough.py‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,14 @@ def test_all_cp_methods_in_allowlist(self):
5959
assert expected == EvaluationClient._ALLOWED_CP_METHODS
6060

6161
def test_all_dp_methods_in_allowlist(self):
62-
expected = {"evaluate"}
62+
expected = {
63+
"evaluate",
64+
"start_batch_evaluation",
65+
"get_batch_evaluation",
66+
"list_batch_evaluations",
67+
"stop_batch_evaluation",
68+
"delete_batch_evaluation",
69+
}
6370
assert expected == EvaluationClient._ALLOWED_DP_METHODS
6471

6572

@@ -153,3 +160,57 @@ def test_delete_online_eval_config_and_wait(self, _mock_sleep):
153160
onlineEvaluationConfigId="c-123",
154161
)
155162
client._cp_client.delete_online_evaluation_config.assert_called_once()
163+
164+
165+
class TestBatchEvaluationAndWait:
166+
"""Tests for batch evaluation passthrough and start_batch_evaluation_and_wait."""
167+
168+
def _make_client(self):
169+
with patch("boto3.client"):
170+
client = EvaluationClient(region_name="us-west-2")
171+
client._cp_client = Mock()
172+
client._dp_client = Mock()
173+
return client
174+
175+
@pytest.mark.parametrize(
176+
"method",
177+
[
178+
"start_batch_evaluation",
179+
"get_batch_evaluation",
180+
"list_batch_evaluations",
181+
"stop_batch_evaluation",
182+
"delete_batch_evaluation",
183+
],
184+
)
185+
def test_batch_methods_forwarded(self, method):
186+
client = self._make_client()
187+
getattr(client, method)(batch_evaluation_id="b-1")
188+
getattr(client._dp_client, method).assert_called_once_with(batchEvaluationId="b-1")
189+
190+
@pytest.mark.parametrize("done", ["COMPLETED", "COMPLETED_WITH_ERRORS"])
191+
@patch("bedrock_agentcore._utils.polling.time.sleep")
192+
def test_returns_when_done(self, _mock_sleep, done):
193+
client = self._make_client()
194+
client._dp_client.start_batch_evaluation.return_value = {"batchEvaluationId": "b-1"}
195+
client._dp_client.get_batch_evaluation.side_effect = [
196+
{"status": "IN_PROGRESS"},
197+
{"status": done, "insightResults": [{"name": "Timeouts"}]},
198+
]
199+
result = client.start_batch_evaluation_and_wait(
200+
batch_evaluation_name="run",
201+
evaluators=[{"evaluatorId": "Builtin.FailureAnalysis"}],
202+
)
203+
assert result["status"] == done
204+
assert result["insightResults"] == [{"name": "Timeouts"}]
205+
client._dp_client.start_batch_evaluation.assert_called_once_with(
206+
batchEvaluationName="run", evaluators=[{"evaluatorId": "Builtin.FailureAnalysis"}]
207+
)
208+
client._dp_client.get_batch_evaluation.assert_called_with(batchEvaluationId="b-1")
209+
210+
@pytest.mark.parametrize("failed", ["FAILED", "STOPPED"])
211+
def test_raises_on_failure(self, failed):
212+
client = self._make_client()
213+
client._dp_client.start_batch_evaluation.return_value = {"batchEvaluationId": "b-1"}
214+
client._dp_client.get_batch_evaluation.return_value = {"status": failed, "errorDetails": ["no spans"]}
215+
with pytest.raises(RuntimeError, match="no spans"):
216+
client.start_batch_evaluation_and_wait(batch_evaluation_name="run")

0 commit comments

Comments
 (0)