Skip to content

Commit 5a29c45

Browse files
feat(evaluators): add AgentControl support for Orbit preset SLM scorer invocation (#259)
## Summary - Adds AgentControl support for Orbit preset SLM scorer invocation and is paired with rungalileo/orbit#1720. - Requires `scorer_id`; `scorer_label` is optional display metadata and `scorer_version_id` is a deprecated compatibility identifier. - Calls `/api/v1/scorers/invoke` with a scoped internal HS256 JWT (`scorers.invoke`). - Dual-writes legacy `inputs` and a structured `record` during the rollout, including selector-aligned input/output plus Step context, tools, and ground truth. - Sends an Orbit execution timeout below the AgentControl HTTP deadline (8 seconds for the default 10-second deadline). ## Scope - Orbit owns feature-flag routing between RuntimeScorer and the legacy adapter. - Nine flat preset SLM scorers are validated for direct runtime execution. - Existing Step context propagation through models, engine, integrations, and SDKs is retained. - Broader preset SLM runtime coverage is deferred to Jira phases 2 and 3. - Customer-owned Luna scorers are not supported in this release. - No backend selector, Protect payload construction, Wizard configuration, trusted tenant identity, or Galileo record-tree construction is added. ## Risk and Rollout - Risk level: medium. - Deploy Orbit #1720 and keep its legacy adapter available for the tandem rollout. - Roll back AgentControl to the legacy `inputs.query`/`inputs.response` contract if needed. - Retain dual-write until older supported Orbit versions are retired. ## Testing - [x] Galileo evaluator tests (116 passed) - [x] Models tests (71 passed) - [x] Engine tests (117 passed) - [x] Affected Python SDK tests (103 passed) - [x] Built-in evaluator tests (310 passed) - [x] TypeScript SDK tests, lint, and typecheck - [x] Repository lint, mypy, and `git diff --check` - [ ] Full Python SDK target requires local PostgreSQL on `localhost:5432` ## Follow-up Orbit #1720 must update all six AgentControl git references to commit `8a2b5d52717090ed7c65b627035f73d89c46f5ff` and regenerate requirements before the tandem merge.
1 parent 7cb21af commit 5a29c45

33 files changed

Lines changed: 1231 additions & 65 deletions

File tree

‎engine/pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ version = "8.6.0"
44
description = "Control execution engine for Agent Control"
55
requires-python = ">=3.12"
66
dependencies = [
7-
"agent-control-models>=3.0.0",
8-
"agent-control-evaluators>=3.0.0",
7+
"agent-control-models>=8.6.0",
8+
"agent-control-evaluators>=8.6.0",
99
"google-re2>=1.1",
1010
]
1111
authors = [

‎engine/src/agent_control_engine/core.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ async def _evaluate_leaf(
320320
timeout = DEFAULT_EVALUATOR_TIMEOUT
321321

322322
result = await asyncio.wait_for(
323-
evaluator.evaluate(data),
323+
evaluator.evaluate_with_context(data, request.step),
324324
timeout=timeout,
325325
)
326326
except TimeoutError:

‎engine/tests/test_core.py‎

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,15 @@ class SimpleConfig(BaseModel):
3939
# Shared state for coordination between test evaluators
4040
_execution_log: list[str] = []
4141
_blocker_event: asyncio.Event | None = None
42+
_context_calls: list[tuple[Any, Step]] = []
4243

4344

4445
def reset_test_state() -> None:
4546
"""Reset shared test state."""
46-
global _execution_log, _blocker_event
47+
global _execution_log, _blocker_event, _context_calls
4748
_execution_log = []
4849
_blocker_event = asyncio.Event()
50+
_context_calls = []
4951

5052

5153
class AllowEvaluator(Evaluator[SimpleConfig]):
@@ -163,6 +165,24 @@ async def evaluate(self, data: Any) -> EvaluatorResult:
163165
return result
164166

165167

168+
class ContextEvaluator(Evaluator[SimpleConfig]):
169+
"""Evaluator that records selector data and full request context."""
170+
171+
metadata = EvaluatorMetadata(
172+
name="test-context",
173+
version="1.0.0",
174+
description="Records contextual calls",
175+
)
176+
config_model = SimpleConfig
177+
178+
async def evaluate(self, data: Any) -> EvaluatorResult:
179+
raise AssertionError("engine should call evaluate_with_context")
180+
181+
async def evaluate_with_context(self, data: Any, step: Step) -> EvaluatorResult:
182+
_context_calls.append((data, step))
183+
return EvaluatorResult(matched=False, confidence=1.0, message="context received")
184+
185+
166186
@dataclass
167187
class MockControlWithIdentity:
168188
"""Mock control for testing."""
@@ -185,6 +205,7 @@ def setup_test_evaluators():
185205
BlockerEvaluator,
186206
SlowEvaluator,
187207
MetadataEvaluator,
208+
ContextEvaluator,
188209
]:
189210
try:
190211
register_evaluator(evaluator_cls)
@@ -253,6 +274,69 @@ def make_control(
253274
)
254275

255276

277+
@pytest.mark.asyncio
278+
async def test_context_evaluator_receives_selected_data_and_complete_step() -> None:
279+
# Given: a selector targeting output and a step with structured context
280+
engine = ControlEngine(
281+
[make_control(1, "context", "test-context", action="observe", path="output")]
282+
)
283+
step = Step(
284+
type="llm",
285+
name="test-step",
286+
input="question",
287+
output="answer",
288+
context={"conversation_id": "c-1"},
289+
tools=[{"name": "search", "description": "Search", "input_schema": {}}],
290+
ground_truth="expected answer",
291+
)
292+
293+
# When: evaluating the request through the engine
294+
await engine.process(
295+
EvaluationRequest(
296+
agent_name="00000000-0000-0000-0000-000000000001",
297+
step=step,
298+
stage="pre",
299+
)
300+
)
301+
302+
# Then: selector behavior is unchanged and the complete Step is separate
303+
assert _context_calls == [("answer", step)]
304+
305+
306+
@pytest.mark.asyncio
307+
async def test_cached_context_evaluator_handles_concurrent_steps_without_retaining_state() -> None:
308+
# Given: one cached evaluator configuration and two independent requests
309+
engine = ControlEngine(
310+
[make_control(1, "context", "test-context", action="observe", path="input")]
311+
)
312+
first = Step(type="llm", name="test-step", input="first", ground_truth="one")
313+
second = Step(type="llm", name="test-step", input="second", ground_truth="two")
314+
315+
# When: the requests are evaluated concurrently
316+
await asyncio.gather(
317+
engine.process(
318+
EvaluationRequest(
319+
agent_name="00000000-0000-0000-0000-000000000001",
320+
step=first,
321+
stage="pre",
322+
)
323+
),
324+
engine.process(
325+
EvaluationRequest(
326+
agent_name="00000000-0000-0000-0000-000000000001",
327+
step=second,
328+
stage="pre",
329+
)
330+
),
331+
)
332+
333+
# Then: each selected value remains paired with its own full Step
334+
assert {(data, step.ground_truth) for data, step in _context_calls} == {
335+
("first", "one"),
336+
("second", "two"),
337+
}
338+
339+
256340
# =============================================================================
257341
# Test: Parallel Execution
258342
# =============================================================================

‎evaluators/builtin/pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@ requires-python = ">=3.12"
77
license = { text = "Apache-2.0" }
88
authors = [{ name = "Agent Control Team" }]
99
dependencies = [
10-
"agent-control-models>=7.5.0",
10+
"agent-control-models>=8.6.0",
1111
"pydantic>=2.12.4",
1212
"google-re2>=1.1",
1313
"jsonschema>=4.0.0",
1414
"sqlglot[c]>=30.11.0,<30.12.0",
1515
]
1616

1717
[project.optional-dependencies]
18-
galileo = ["agent-control-evaluator-galileo>=7.5.0"]
18+
galileo = ["agent-control-evaluator-galileo>=8.6.0"]
1919
budget = ["agent-control-evaluator-budget>=7.5.0"]
2020
cisco = ["agent-control-evaluator-cisco>=7.5.0"]
2121
defenseclaw = ["agent-control-evaluator-defenseclaw>=8.2.0"]

‎evaluators/builtin/src/agent_control_evaluators/_base.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from dataclasses import dataclass
88
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar
99

10-
from agent_control_models import EvaluatorResult
10+
from agent_control_models import EvaluatorResult, Step
1111
from agent_control_models.base import BaseModel
1212

1313
if TYPE_CHECKING:
@@ -161,6 +161,23 @@ async def evaluate(self, data: Any) -> EvaluatorResult:
161161
"""
162162
pass
163163

164+
async def evaluate_with_context(self, data: Any, step: Step) -> EvaluatorResult:
165+
"""Evaluate selected data with access to the complete runtime step.
166+
167+
The default implementation preserves compatibility with evaluators that
168+
implement only :meth:`evaluate`. Evaluators must treat ``step`` as
169+
immutable request-scoped context because evaluator instances are cached
170+
and may be invoked concurrently.
171+
172+
Args:
173+
data: Data extracted by the configured selector.
174+
step: Complete runtime step for the current request.
175+
176+
Returns:
177+
EvaluatorResult produced by this evaluator.
178+
"""
179+
return await self.evaluate(data)
180+
164181
def get_timeout_seconds(self) -> float:
165182
"""Get timeout in seconds from config or metadata default."""
166183
timeout_ms: int = getattr(self.config, "timeout_ms", self.metadata.timeout_ms)

‎evaluators/builtin/tests/test_base.py‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
Architecture: Evaluators take config at __init__, evaluate() only takes data.
44
"""
55

6-
import pytest
76
from typing import Any
87

8+
import pytest
9+
910
from agent_control_evaluators import Evaluator, EvaluatorConfig, EvaluatorMetadata
10-
from agent_control_models import EvaluatorResult
11+
from agent_control_models import EvaluatorResult, Step
1112

1213

1314
class MockConfig(EvaluatorConfig):
@@ -106,6 +107,20 @@ async def test_mock_evaluator_evaluate_no_match(self):
106107

107108
assert result.matched is False
108109

110+
@pytest.mark.asyncio
111+
async def test_contextual_evaluation_delegates_to_existing_evaluate(self):
112+
"""Existing evaluators receive selected data through the default hook."""
113+
# Given: an evaluator that implements only evaluate(data)
114+
evaluator = MockEvaluator.from_dict({"should_match": True})
115+
step = Step(type="llm", name="answer", input="full input")
116+
117+
# When: the engine-facing contextual hook is called
118+
result = await evaluator.evaluate_with_context("selected data", step)
119+
120+
# Then: the legacy evaluate implementation handles the selected data
121+
assert result.matched is True
122+
assert result.metadata == {"data": "selected data"}
123+
109124
def test_evaluator_config_stored(self):
110125
"""Test that evaluator stores config."""
111126
evaluator = MockEvaluator.from_dict({"should_match": True})

‎evaluators/contrib/galileo/README.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,24 @@ Integration package for Galileo Luna evaluator.
77
The `galileo.luna2` evaluator ID has been removed. Existing controls that use
88
`galileo.luna2` should migrate to `galileo.luna` and update their evaluator
99
configuration to use the direct Luna scorer fields. `scorer_id` is required;
10-
`scorer_label` and `scorer_version_id` are optional. The evaluator calls the
10+
`scorer_label` and `scorer_version_id` are optional. `scorer_version_id` is a
11+
deprecated optional compatibility identifier; Orbit currently invokes the
12+
scorer's current default version. The evaluator calls the
1113
URL configured by `GALILEO_LUNA_INVOKE_URL`; the target must support the Luna
1214
scorer invoke request/response contract and internal Galileo secret auth. Also
1315
set `threshold` and `operator` as needed. If you still need the legacy Luna2
1416
evaluator, pin
1517
`agent-control-evaluator-galileo <8`.
1618

19+
The optional evaluator `config` mirrors Orbit's allowlisted scorer-invoke
20+
configuration. Supported keys are `threshold`, `score_threshold`, and
21+
`request_timeout_seconds`; unsupported keys are rejected locally before an HTTP
22+
request is made.
23+
24+
Agent Control always sends an Orbit execution timeout shorter than its HTTP
25+
deadline. When `request_timeout_seconds` is omitted, it defaults to 80% of the
26+
configured HTTP timeout (8 seconds for the default 10-second deadline).
27+
1728
## Install
1829

1930
Canonical install path:

‎evaluators/contrib/galileo/pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ requires-python = ">=3.12"
77
license = { text = "Apache-2.0" }
88
authors = [{ name = "Agent Control Team" }]
99
dependencies = [
10-
"agent-control-evaluators>=7.5.0",
11-
"agent-control-models>=7.5.0",
10+
"agent-control-evaluators>=8.6.0",
11+
"agent-control-models>=8.6.0",
1212
"httpx>=0.24.0",
1313
"pydantic>=2.12.4",
1414
]

‎evaluators/contrib/galileo/src/agent_control_evaluator_galileo/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,17 @@
2525
LunaEvaluator,
2626
LunaEvaluatorConfig,
2727
LunaOperator,
28+
ScorerInvokeConfig,
29+
ScorerInvokeRecord,
2830
ScorerInvokeRequest,
2931
ScorerInvokeResponse,
3032
)
3133

3234
__all__ = [
3335
"GalileoLunaClient",
3436
"ScorerInvokeRequest",
37+
"ScorerInvokeConfig",
38+
"ScorerInvokeRecord",
3539
"ScorerInvokeResponse",
3640
"LunaEvaluator",
3741
"LunaEvaluatorConfig",

‎evaluators/contrib/galileo/src/agent_control_evaluator_galileo/luna/__init__.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,22 @@
33
from agent_control_evaluator_galileo.luna.client import (
44
GalileoLunaClient,
55
ScorerInvokeInputs,
6+
ScorerInvokeRecord,
67
ScorerInvokeRequest,
78
ScorerInvokeResponse,
89
)
9-
from agent_control_evaluator_galileo.luna.config import LunaEvaluatorConfig, LunaOperator
10+
from agent_control_evaluator_galileo.luna.config import (
11+
LunaEvaluatorConfig,
12+
LunaOperator,
13+
ScorerInvokeConfig,
14+
)
1015
from agent_control_evaluator_galileo.luna.evaluator import LUNA_AVAILABLE, LunaEvaluator
1116

1217
__all__ = [
1318
"GalileoLunaClient",
1419
"ScorerInvokeInputs",
20+
"ScorerInvokeConfig",
21+
"ScorerInvokeRecord",
1522
"ScorerInvokeRequest",
1623
"ScorerInvokeResponse",
1724
"LunaEvaluatorConfig",

0 commit comments

Comments
 (0)