Skip to content

Commit 09f45f3

Browse files
authored
feat: add custom code-based evaluator decorator and typed models (#383)
* feat: add custom code-based evaluator decorator and typed models Add @custom_code_based_evaluator() decorator that wraps typed Python functions into Lambda handlers for Bedrock AgentCore evaluation. - EvaluatorInput/EvaluatorOutput Pydantic models for typed Lambda I/O - Decorator parses Lambda events, handles null evaluationTarget, and serializes results via model_dump() - .unwrapped attribute for unit testing without Lambda overhead - Context passthrough via signature inspection - errorCode/errorMessage fields for evaluation failures * fix: apply ruff formatting to decorator.py
1 parent daaa807 commit 09f45f3

7 files changed

Lines changed: 347 additions & 1 deletion

File tree

src/bedrock_agentcore/evaluation/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
"""AgentCore Evaluation: EvaluationClient, OnDemandEvaluationDatasetRunner, and Strands integration."""
22

33
from bedrock_agentcore.evaluation.client import EvaluationClient, ReferenceInputs
4+
from bedrock_agentcore.evaluation.custom_code_based_evaluators import (
5+
EvaluatorInput,
6+
EvaluatorOutput,
7+
custom_code_based_evaluator,
8+
)
49
from bedrock_agentcore.evaluation.runner.dataset_providers import (
510
DatasetProvider,
611
FileDatasetProvider,
@@ -47,11 +52,13 @@
4752
"EvaluationClient",
4853
"EvaluationResult",
4954
"EvaluationRunConfig",
50-
"OnDemandEvaluationDatasetRunner",
5155
"EvaluatorConfig",
56+
"EvaluatorInput",
57+
"EvaluatorOutput",
5258
"EvaluatorResult",
5359
"FileDatasetProvider",
5460
"Input",
61+
"OnDemandEvaluationDatasetRunner",
5562
"ReferenceInputs",
5663
"Scenario",
5764
"ScenarioExecutionResult",
@@ -62,6 +69,7 @@
6269
"Turn",
6370
"PredefinedScenario",
6471
"PredefinedScenarioExecutor",
72+
"custom_code_based_evaluator",
6573
"convert_strands_to_adot",
6674
"create_strands_evaluator",
6775
"fetch_spans_from_cloudwatch",
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Code-based evaluator support for AgentCore Evaluation."""
2+
3+
from bedrock_agentcore.evaluation.custom_code_based_evaluators.decorator import custom_code_based_evaluator
4+
from bedrock_agentcore.evaluation.custom_code_based_evaluators.models import EvaluatorInput, EvaluatorOutput
5+
6+
__all__ = [
7+
"custom_code_based_evaluator",
8+
"EvaluatorInput",
9+
"EvaluatorOutput",
10+
]
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Decorator that adapts a typed evaluator function into a Lambda handler."""
2+
3+
import functools
4+
import logging
5+
6+
from bedrock_agentcore.evaluation.custom_code_based_evaluators.models import EvaluatorInput, EvaluatorOutput
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
def custom_code_based_evaluator():
12+
"""Decorator that wraps a typed evaluator function as a Lambda handler.
13+
14+
The decorated function receives an ``EvaluatorInput`` and the Lambda
15+
``context``, and returns an ``EvaluatorOutput``. The decorator handles
16+
parsing the raw Lambda event dict into ``EvaluatorInput`` and serializing
17+
the ``EvaluatorOutput`` into the response contract expected by the
18+
evaluation service.
19+
20+
Must be called with parentheses: ``@custom_code_based_evaluator()``.
21+
22+
Example::
23+
24+
@custom_code_based_evaluator()
25+
def handler(input: EvaluatorInput, context) -> EvaluatorOutput:
26+
return EvaluatorOutput(value=1.0, label="Pass")
27+
"""
28+
29+
def decorator(fn):
30+
@functools.wraps(fn)
31+
def lambda_handler(event, context=None):
32+
logger.debug("Raw Lambda event: %s", event)
33+
34+
target = event.get("evaluationTarget") or {}
35+
trace_ids = target.get("traceIds") or []
36+
span_ids = target.get("spanIds") or []
37+
38+
evaluator_input = EvaluatorInput(
39+
evaluation_level=event["evaluationLevel"],
40+
session_spans=event["evaluationInput"]["sessionSpans"],
41+
target_trace_id=trace_ids[0] if trace_ids else None,
42+
target_span_id=span_ids[0] if span_ids else None,
43+
schema_version=event.get("schemaVersion", "1.0"),
44+
)
45+
46+
result = fn(evaluator_input, context)
47+
48+
if not isinstance(result, EvaluatorOutput):
49+
raise TypeError(f"Evaluator must return an EvaluatorOutput, got {type(result).__name__}")
50+
51+
return result.model_dump()
52+
53+
lambda_handler.unwrapped = fn
54+
return lambda_handler
55+
56+
return decorator
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Typed models for code-based evaluator Lambda input and output."""
2+
3+
from typing import Dict, List, Optional
4+
5+
from pydantic import BaseModel
6+
7+
8+
class EvaluatorInput(BaseModel):
9+
"""Parsed input for a code-based evaluator Lambda function.
10+
11+
Attributes:
12+
evaluation_level: The evaluation granularity - "SESSION", "TRACE", or "TOOL_CALL".
13+
session_spans: Raw ADOT span dicts from the evaluation service.
14+
target_trace_id: The target trace ID (set for TRACE level, None otherwise).
15+
target_span_id: The target span ID (set for TOOL_CALL level, None otherwise).
16+
schema_version: Schema version of the Lambda contract.
17+
"""
18+
19+
evaluation_level: str
20+
session_spans: List[Dict]
21+
target_trace_id: Optional[str] = None
22+
target_span_id: Optional[str] = None
23+
schema_version: str = "1.0"
24+
25+
26+
class EvaluatorOutput(BaseModel):
27+
"""Result returned by a code-based evaluator function.
28+
29+
Attributes:
30+
value: Numerical score for the evaluation.
31+
label: Categorical label (e.g. "Pass", "Fail"). Required.
32+
explanation: Optional explanation of the evaluation result.
33+
"""
34+
35+
value: Optional[float] = None
36+
label: str
37+
explanation: Optional[str] = None
38+
errorCode: Optional[str] = None
39+
errorMessage: Optional[str] = None

tests/bedrock_agentcore/evaluation/custom_code_based_evaluators/__init__.py

Whitespace-only changes.
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""Tests for the @custom_code_based_evaluator decorator."""
2+
3+
import pytest
4+
5+
from bedrock_agentcore.evaluation.custom_code_based_evaluators import (
6+
EvaluatorInput,
7+
EvaluatorOutput,
8+
custom_code_based_evaluator,
9+
)
10+
11+
12+
def _make_event(level="TRACE", trace_ids=None, span_ids=None):
13+
"""Build a raw Lambda event dict."""
14+
event = {
15+
"schemaVersion": "1.0",
16+
"evaluationLevel": level,
17+
"evaluationInput": {
18+
"sessionSpans": [
19+
{"traceId": "abc123", "spanId": "span1", "name": "Agent", "attributes": {}},
20+
]
21+
},
22+
"evaluationTarget": {},
23+
}
24+
if trace_ids is not None:
25+
event["evaluationTarget"]["traceIds"] = trace_ids
26+
if span_ids is not None:
27+
event["evaluationTarget"]["spanIds"] = span_ids
28+
return event
29+
30+
31+
class TestDecoratorWithRawEvent:
32+
def test_trace_level(self):
33+
@custom_code_based_evaluator()
34+
def handler(inp, context):
35+
return EvaluatorOutput(value=1.0, label="Pass", explanation="Valid")
36+
37+
event = _make_event(level="TRACE", trace_ids=["abc123"])
38+
result = handler(event)
39+
40+
assert result["value"] == 1.0
41+
assert result["label"] == "Pass"
42+
assert result["explanation"] == "Valid"
43+
44+
def test_session_level(self):
45+
@custom_code_based_evaluator()
46+
def handler(inp, context):
47+
return EvaluatorOutput(value=0.5, label="Partial")
48+
49+
event = _make_event(level="SESSION")
50+
result = handler(event)
51+
52+
assert result["value"] == 0.5
53+
assert result["label"] == "Partial"
54+
assert result["explanation"] is None
55+
56+
def test_tool_call_level(self):
57+
@custom_code_based_evaluator()
58+
def handler(inp, context):
59+
return EvaluatorOutput(value=0.0, label="Fail")
60+
61+
event = _make_event(level="TOOL_CALL", span_ids=["span1"])
62+
result = handler(event)
63+
64+
assert result["value"] == 0.0
65+
assert result["label"] == "Fail"
66+
67+
def test_default_schema_version(self):
68+
@custom_code_based_evaluator()
69+
def handler(inp, context):
70+
assert inp.schema_version == "1.0"
71+
return EvaluatorOutput(value=1.0, label="Pass")
72+
73+
event = _make_event()
74+
del event["schemaVersion"]
75+
handler(event)
76+
77+
78+
class TestExceptionPropagation:
79+
def test_exception_propagates(self):
80+
@custom_code_based_evaluator()
81+
def handler(inp, context):
82+
raise RuntimeError("boom")
83+
84+
event = _make_event()
85+
with pytest.raises(RuntimeError, match="boom"):
86+
handler(event)
87+
88+
89+
class TestFunctoolsWraps:
90+
def test_preserves_name(self):
91+
@custom_code_based_evaluator()
92+
def my_evaluator(inp, context):
93+
return EvaluatorOutput(value=1.0, label="Pass")
94+
95+
assert my_evaluator.__name__ == "my_evaluator"
96+
97+
def test_preserves_module(self):
98+
@custom_code_based_evaluator()
99+
def my_evaluator(inp, context):
100+
return EvaluatorOutput(value=1.0, label="Pass")
101+
102+
assert my_evaluator.__module__ == __name__
103+
104+
105+
class TestContextPassthrough:
106+
def test_context_passed_to_function(self):
107+
received_context = []
108+
109+
@custom_code_based_evaluator()
110+
def handler(inp, context):
111+
received_context.append(context)
112+
return EvaluatorOutput(value=1.0, label="Pass")
113+
114+
mock_context = {"function_name": "my-lambda"}
115+
event = _make_event()
116+
handler(event, mock_context)
117+
118+
assert received_context == [mock_context]
119+
120+
def test_context_defaults_to_none(self):
121+
received_context = []
122+
123+
@custom_code_based_evaluator()
124+
def handler(inp, context):
125+
received_context.append(context)
126+
return EvaluatorOutput(value=1.0, label="Pass")
127+
128+
event = _make_event()
129+
handler(event)
130+
131+
assert received_context == [None]
132+
133+
134+
class TestReturnTypeValidation:
135+
def test_rejects_dict_return(self):
136+
@custom_code_based_evaluator()
137+
def handler(inp, context):
138+
return {"value": 1.0, "label": "Pass"}
139+
140+
event = _make_event()
141+
with pytest.raises(TypeError, match="Evaluator must return an EvaluatorOutput, got dict"):
142+
handler(event)
143+
144+
def test_rejects_none_return(self):
145+
@custom_code_based_evaluator()
146+
def handler(inp, context):
147+
return None
148+
149+
event = _make_event()
150+
with pytest.raises(TypeError, match="Evaluator must return an EvaluatorOutput, got NoneType"):
151+
handler(event)
152+
153+
154+
class TestUnwrapped:
155+
def test_unwrapped_returns_evaluator_output(self):
156+
@custom_code_based_evaluator()
157+
def handler(inp, context):
158+
return EvaluatorOutput(value=1.0, label="Pass")
159+
160+
inp = EvaluatorInput(
161+
evaluation_level="TRACE",
162+
session_spans=[],
163+
target_trace_id="t1",
164+
target_span_id=None,
165+
schema_version="1.0",
166+
)
167+
result = handler.unwrapped(inp, None)
168+
assert isinstance(result, EvaluatorOutput)
169+
assert result.value == 1.0
170+
assert result.label == "Pass"
171+
172+
def test_unwrapped_is_original_function(self):
173+
def my_eval(inp, context):
174+
return EvaluatorOutput(value=0.5, label="Partial")
175+
176+
handler = custom_code_based_evaluator()(my_eval)
177+
assert handler.unwrapped is my_eval
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Tests for EvaluatorInput and EvaluatorOutput dataclasses."""
2+
3+
from bedrock_agentcore.evaluation.custom_code_based_evaluators.models import EvaluatorInput, EvaluatorOutput
4+
5+
6+
class TestEvaluatorInput:
7+
def test_all_fields(self):
8+
inp = EvaluatorInput(
9+
evaluation_level="TRACE",
10+
session_spans=[{"traceId": "t1", "spanId": "s1"}],
11+
target_trace_id="t1",
12+
target_span_id=None,
13+
schema_version="1.0",
14+
)
15+
assert inp.evaluation_level == "TRACE"
16+
assert inp.session_spans == [{"traceId": "t1", "spanId": "s1"}]
17+
assert inp.target_trace_id == "t1"
18+
assert inp.target_span_id is None
19+
assert inp.schema_version == "1.0"
20+
21+
def test_session_level_no_targets(self):
22+
inp = EvaluatorInput(
23+
evaluation_level="SESSION",
24+
session_spans=[],
25+
target_trace_id=None,
26+
target_span_id=None,
27+
schema_version="1.0",
28+
)
29+
assert inp.target_trace_id is None
30+
assert inp.target_span_id is None
31+
32+
33+
class TestEvaluatorOutput:
34+
def test_defaults(self):
35+
out = EvaluatorOutput(label="Pass")
36+
assert out.value is None
37+
assert out.label == "Pass"
38+
assert out.explanation is None
39+
40+
def test_all_fields(self):
41+
out = EvaluatorOutput(value=0.85, label="Pass", explanation="Looks good")
42+
assert out.value == 0.85
43+
assert out.label == "Pass"
44+
assert out.explanation == "Looks good"
45+
46+
def test_label_required(self):
47+
import pytest
48+
from pydantic import ValidationError
49+
50+
with pytest.raises(ValidationError):
51+
EvaluatorOutput(value=1.0)
52+
53+
def test_label_only(self):
54+
out = EvaluatorOutput(label="Fail")
55+
assert out.label == "Fail"
56+
assert out.value is None

0 commit comments

Comments
 (0)