Skip to content

Commit 2f1e5fe

Browse files
feat(engine): add evaluation runtime tuning (#239)
1 parent fc516f0 commit 2f1e5fe

5 files changed

Lines changed: 512 additions & 30 deletions

File tree

engine/src/agent_control_engine/core.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,33 @@
2727

2828
logger = logging.getLogger(__name__)
2929

30+
31+
def _env_positive_int(*names: str, default: int) -> int:
32+
"""Read a positive integer from the first configured environment variable."""
33+
for name in names:
34+
value = os.environ.get(name)
35+
if value is None or value.strip() == "":
36+
continue
37+
try:
38+
parsed = int(value)
39+
except ValueError as exc:
40+
raise RuntimeError(f"{name}={value!r} must be an integer.") from exc
41+
if parsed < 1:
42+
raise RuntimeError(f"{name}={value!r} must be greater than or equal to 1.")
43+
return parsed
44+
return default
45+
46+
3047
# Default timeout for evaluator execution (seconds)
3148
DEFAULT_EVALUATOR_TIMEOUT = float(os.environ.get("EVALUATOR_TIMEOUT_SECONDS", "30"))
3249

33-
# Max concurrent evaluations (limits task spawning overhead for large policies)
34-
MAX_CONCURRENT_EVALUATIONS = int(os.environ.get("MAX_CONCURRENT_EVALUATIONS", "3"))
50+
# Max concurrent evaluations (limits task spawning overhead for large policies).
51+
# Prefer the namespaced env var; MAX_CONCURRENT_EVALUATIONS is kept for compatibility.
52+
MAX_CONCURRENT_EVALUATIONS = _env_positive_int(
53+
"AGENT_CONTROL_MAX_CONCURRENT_EVALUATIONS",
54+
"MAX_CONCURRENT_EVALUATIONS",
55+
default=3,
56+
)
3557

3658
SELECTED_DATA_PREVIEW_MAX_CHARS = int(
3759
os.environ.get("AGENT_CONTROL_SELECTED_DATA_PREVIEW_MAX_CHARS", "500")

engine/tests/test_core.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,6 +1280,57 @@ async def test_timeout_does_not_affect_fast_evaluators(self):
12801280
class TestConcurrencyLimit:
12811281
"""Tests for semaphore-based concurrency limiting."""
12821282

1283+
def test_max_concurrency_env_prefers_agent_control_name(
1284+
self, monkeypatch: pytest.MonkeyPatch
1285+
) -> None:
1286+
"""The canonical Agent Control env var overrides the legacy short name."""
1287+
import agent_control_engine.core as core_module
1288+
1289+
monkeypatch.setenv("AGENT_CONTROL_MAX_CONCURRENT_EVALUATIONS", "7")
1290+
monkeypatch.setenv("MAX_CONCURRENT_EVALUATIONS", "2")
1291+
1292+
assert (
1293+
core_module._env_positive_int(
1294+
"AGENT_CONTROL_MAX_CONCURRENT_EVALUATIONS",
1295+
"MAX_CONCURRENT_EVALUATIONS",
1296+
default=3,
1297+
)
1298+
== 7
1299+
)
1300+
1301+
def test_max_concurrency_env_reads_legacy_name(
1302+
self, monkeypatch: pytest.MonkeyPatch
1303+
) -> None:
1304+
"""The existing env var remains supported for compatibility."""
1305+
import agent_control_engine.core as core_module
1306+
1307+
monkeypatch.delenv("AGENT_CONTROL_MAX_CONCURRENT_EVALUATIONS", raising=False)
1308+
monkeypatch.setenv("MAX_CONCURRENT_EVALUATIONS", "5")
1309+
1310+
assert (
1311+
core_module._env_positive_int(
1312+
"AGENT_CONTROL_MAX_CONCURRENT_EVALUATIONS",
1313+
"MAX_CONCURRENT_EVALUATIONS",
1314+
default=3,
1315+
)
1316+
== 5
1317+
)
1318+
1319+
def test_max_concurrency_env_rejects_non_positive_values(
1320+
self, monkeypatch: pytest.MonkeyPatch
1321+
) -> None:
1322+
"""The concurrency cap must always allow at least one evaluator."""
1323+
import agent_control_engine.core as core_module
1324+
1325+
monkeypatch.setenv("AGENT_CONTROL_MAX_CONCURRENT_EVALUATIONS", "0")
1326+
1327+
with pytest.raises(RuntimeError, match="greater than or equal to 1"):
1328+
core_module._env_positive_int(
1329+
"AGENT_CONTROL_MAX_CONCURRENT_EVALUATIONS",
1330+
"MAX_CONCURRENT_EVALUATIONS",
1331+
default=3,
1332+
)
1333+
12831334
@pytest.mark.asyncio
12841335
async def test_concurrency_limited_to_max(self, monkeypatch: pytest.MonkeyPatch):
12851336
"""Test that concurrent evaluations are limited by semaphore.

evaluators/contrib/galileo/src/agent_control_evaluator_galileo/luna/client.py

Lines changed: 134 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import os
77
import ssl
88
import warnings
9+
from asyncio import Lock
910
from base64 import urlsafe_b64encode
1011
from hashlib import sha256
1112
from hmac import new as hmac_new
@@ -27,6 +28,11 @@
2728
DEFAULT_KEEPALIVE_EXPIRY_SECS = 1.0
2829
DEFAULT_MAX_CONNECTIONS = 100
2930
DEFAULT_MAX_KEEPALIVE_CONNECTIONS = 20
31+
DEFAULT_CLIENT_POOL_SIZE = 1
32+
LUNA_KEEPALIVE_EXPIRY_ENV = "GALILEO_LUNA_KEEPALIVE_EXPIRY_SECONDS"
33+
LUNA_MAX_CONNECTIONS_ENV = "GALILEO_LUNA_MAX_CONNECTIONS"
34+
LUNA_MAX_KEEPALIVE_CONNECTIONS_ENV = "GALILEO_LUNA_MAX_KEEPALIVE_CONNECTIONS"
35+
LUNA_CLIENT_POOL_SIZE_ENV = "GALILEO_LUNA_CLIENT_POOL_SIZE"
3036
PUBLIC_SCORER_INVOKE_PATH = "/scorers/invoke"
3137
INTERNAL_SCORER_INVOKE_PATH = "/internal/scorers/invoke"
3238
AuthMode = Literal["public", "internal"]
@@ -78,6 +84,54 @@ def _env_auth_mode() -> AuthMode | None:
7884
raise ValueError("GALILEO_LUNA_AUTH_MODE must be either 'public' or 'internal'.")
7985

8086

87+
def _load_float_env(env_name: str, default: float) -> float:
88+
raw = os.getenv(env_name)
89+
if raw is None or raw.strip() == "":
90+
return default
91+
try:
92+
return float(raw)
93+
except ValueError as exc:
94+
raise ValueError(f"{env_name}={raw!r} is not a number.") from exc
95+
96+
97+
def _load_int_env(env_name: str, default: int) -> int:
98+
raw = os.getenv(env_name)
99+
if raw is None or raw.strip() == "":
100+
return default
101+
try:
102+
return int(raw)
103+
except ValueError as exc:
104+
raise ValueError(f"{env_name}={raw!r} is not an integer.") from exc
105+
106+
107+
def _validate_connection_config(
108+
*,
109+
keepalive_expiry_seconds: float,
110+
max_connections: int,
111+
max_keepalive_connections: int,
112+
client_pool_size: int,
113+
) -> None:
114+
if keepalive_expiry_seconds < 0:
115+
raise ValueError(
116+
f"{LUNA_KEEPALIVE_EXPIRY_ENV}={keepalive_expiry_seconds} "
117+
"must be greater than or equal to 0."
118+
)
119+
if max_connections <= 0:
120+
raise ValueError(f"{LUNA_MAX_CONNECTIONS_ENV}={max_connections} must be greater than 0.")
121+
if max_keepalive_connections < 0:
122+
raise ValueError(
123+
f"{LUNA_MAX_KEEPALIVE_CONNECTIONS_ENV}={max_keepalive_connections} "
124+
"must be greater than or equal to 0."
125+
)
126+
if max_keepalive_connections > max_connections:
127+
raise ValueError(
128+
f"{LUNA_MAX_KEEPALIVE_CONNECTIONS_ENV}={max_keepalive_connections} "
129+
f"must be less than or equal to {LUNA_MAX_CONNECTIONS_ENV}={max_connections}."
130+
)
131+
if client_pool_size <= 0:
132+
raise ValueError(f"{LUNA_CLIENT_POOL_SIZE_ENV}={client_pool_size} must be greater than 0.")
133+
134+
81135
def _as_float_or_none(value: JSONValue) -> float | None:
82136
if isinstance(value, bool) or value is None:
83137
return None
@@ -184,6 +238,10 @@ class GalileoLunaClient:
184238
GALILEO_API_URL: Galileo API URL fallback.
185239
GALILEO_LUNA_CA_FILE: CA bundle used to verify the scorer API endpoint, for
186240
deployments whose API serves an internally-issued TLS certificate.
241+
GALILEO_LUNA_KEEPALIVE_EXPIRY_SECONDS: HTTP pooled connection expiry.
242+
GALILEO_LUNA_MAX_CONNECTIONS: Maximum outbound HTTP connections.
243+
GALILEO_LUNA_MAX_KEEPALIVE_CONNECTIONS: Maximum idle pooled HTTP connections.
244+
GALILEO_LUNA_CLIENT_POOL_SIZE: Number of outbound HTTP clients to rotate across.
187245
GALILEO_CONSOLE_URL: Galileo Console URL (optional, defaults to production).
188246
"""
189247

@@ -235,7 +293,26 @@ def __init__(
235293
self.api_base = self._resolve_api_base(api_url)
236294
self.ca_file = (ca_file or os.getenv("GALILEO_LUNA_CA_FILE") or "").strip() or None
237295
self._ssl_context = self._load_ssl_context(self.ca_file)
296+
self.keepalive_expiry_seconds = _load_float_env(
297+
LUNA_KEEPALIVE_EXPIRY_ENV, DEFAULT_KEEPALIVE_EXPIRY_SECS
298+
)
299+
self.max_connections = _load_int_env(LUNA_MAX_CONNECTIONS_ENV, DEFAULT_MAX_CONNECTIONS)
300+
self.max_keepalive_connections = _load_int_env(
301+
LUNA_MAX_KEEPALIVE_CONNECTIONS_ENV, DEFAULT_MAX_KEEPALIVE_CONNECTIONS
302+
)
303+
self.client_pool_size = _load_int_env(
304+
LUNA_CLIENT_POOL_SIZE_ENV, DEFAULT_CLIENT_POOL_SIZE
305+
)
306+
_validate_connection_config(
307+
keepalive_expiry_seconds=self.keepalive_expiry_seconds,
308+
max_connections=self.max_connections,
309+
max_keepalive_connections=self.max_keepalive_connections,
310+
client_pool_size=self.client_pool_size,
311+
)
238312
self._client: httpx.AsyncClient | None = None
313+
self._clients: list[httpx.AsyncClient] = []
314+
self._next_client_index = 0
315+
self._client_lock = Lock()
239316
logger.info("[GalileoLunaClient] Auth mode selected: %s", self.auth_mode)
240317

241318
def _resolve_api_base(self, api_url: str | None) -> str:
@@ -316,26 +393,48 @@ def _derive_api_url(self, console_url: str) -> str:
316393
parts._replace(netloc=parts.netloc.replace(host, new_host, 1))
317394
)
318395

396+
def _create_client(self) -> httpx.AsyncClient:
397+
"""Create an HTTP client with the configured auth, TLS, and connection limits."""
398+
headers = {"Content-Type": "application/json"}
399+
if self.auth_mode == "public" and self.api_key is not None:
400+
headers["Galileo-API-Key"] = self.api_key
401+
verify: ssl.SSLContext | bool = (
402+
self._ssl_context if self._ssl_context is not None else True
403+
)
404+
return httpx.AsyncClient(
405+
headers=headers,
406+
timeout=httpx.Timeout(DEFAULT_TIMEOUT_SECS),
407+
limits=httpx.Limits(
408+
max_connections=self.max_connections,
409+
max_keepalive_connections=self.max_keepalive_connections,
410+
keepalive_expiry=self.keepalive_expiry_seconds,
411+
),
412+
verify=verify,
413+
)
414+
415+
def _select_pooled_client(self) -> httpx.AsyncClient:
416+
"""Select the next pooled client while holding the client state lock."""
417+
client = self._clients[self._next_client_index % len(self._clients)]
418+
self._next_client_index = (self._next_client_index + 1) % len(self._clients)
419+
return client
420+
319421
async def _get_client(self) -> httpx.AsyncClient:
320-
"""Get or create the HTTP client."""
321-
if self._client is None or self._client.is_closed:
322-
headers = {"Content-Type": "application/json"}
323-
if self.auth_mode == "public" and self.api_key is not None:
324-
headers["Galileo-API-Key"] = self.api_key
325-
verify: ssl.SSLContext | bool = (
326-
self._ssl_context if self._ssl_context is not None else True
327-
)
328-
self._client = httpx.AsyncClient(
329-
headers=headers,
330-
timeout=httpx.Timeout(DEFAULT_TIMEOUT_SECS),
331-
limits=httpx.Limits(
332-
max_connections=DEFAULT_MAX_CONNECTIONS,
333-
max_keepalive_connections=DEFAULT_MAX_KEEPALIVE_CONNECTIONS,
334-
keepalive_expiry=DEFAULT_KEEPALIVE_EXPIRY_SECS,
335-
),
336-
verify=verify,
337-
)
338-
return self._client
422+
"""Get or create the next HTTP client."""
423+
async with self._client_lock:
424+
self._clients = [client for client in self._clients if not client.is_closed]
425+
426+
if self.client_pool_size == 1:
427+
if self._client is not None and not self._client.is_closed:
428+
return self._client
429+
self._client = self._clients[0] if self._clients else self._create_client()
430+
self._clients = [self._client]
431+
return self._client
432+
433+
self._client = None
434+
while len(self._clients) < self.client_pool_size:
435+
self._clients.append(self._create_client())
436+
437+
return self._select_pooled_client()
339438

340439
def _endpoint_and_headers(
341440
self,
@@ -431,9 +530,23 @@ async def invoke(
431530

432531
async def close(self) -> None:
433532
"""Close the HTTP client and release resources."""
434-
if self._client is not None:
435-
await self._client.aclose()
533+
async with self._client_lock:
534+
clients: list[httpx.AsyncClient] = []
535+
seen_client_ids: set[int] = set()
536+
if self._client is not None:
537+
clients.append(self._client)
538+
seen_client_ids.add(id(self._client))
436539
self._client = None
540+
for client in self._clients:
541+
if id(client) not in seen_client_ids:
542+
clients.append(client)
543+
seen_client_ids.add(id(client))
544+
self._clients = []
545+
self._next_client_index = 0
546+
547+
for client in clients:
548+
if not client.is_closed:
549+
await client.aclose()
437550

438551
async def __aenter__(self) -> GalileoLunaClient:
439552
"""Async context manager entry."""

evaluators/contrib/galileo/src/agent_control_evaluator_galileo/luna/evaluator.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from importlib.metadata import PackageNotFoundError, version
99
from typing import Any
1010

11+
import httpx
1112
from agent_control_evaluators import Evaluator, EvaluatorMetadata, register_evaluator
1213
from agent_control_models import EvaluatorResult, JSONValue
1314

@@ -27,6 +28,7 @@ def _resolve_package_version() -> str:
2728

2829
_PACKAGE_VERSION = _resolve_package_version()
2930
LUNA_AVAILABLE = True
31+
_HTTP_ERROR_BODY_LIMIT = 500
3032

3133

3234
def _coerce_payload_text(value: Any) -> str | None:
@@ -74,6 +76,32 @@ def _confidence_from_score(score: JSONValue) -> float:
7476
return 1.0
7577

7678

79+
def _truncated_http_response_body(body: str) -> tuple[str, bool]:
80+
if len(body) <= _HTTP_ERROR_BODY_LIMIT:
81+
return body, False
82+
return body[:_HTTP_ERROR_BODY_LIMIT], True
83+
84+
85+
def _http_status_error_metadata(error: httpx.HTTPStatusError) -> dict[str, Any]:
86+
metadata: dict[str, Any] = {}
87+
88+
request = error.request
89+
metadata["http_method"] = request.method
90+
metadata["http_endpoint_path"] = request.url.path
91+
92+
response = error.response
93+
metadata["http_status_code"] = response.status_code
94+
metadata["http_response_content_type"] = response.headers.get("content-type")
95+
96+
body = response.text
97+
if body:
98+
metadata["http_response_body"], metadata["http_response_body_truncated"] = (
99+
_truncated_http_response_body(body)
100+
)
101+
102+
return {key: value for key, value in metadata.items() if value is not None}
103+
104+
77105
@register_evaluator
78106
class LunaEvaluator(Evaluator[LunaEvaluatorConfig]):
79107
"""Galileo Luna evaluator using the direct scorer invocation API."""
@@ -252,16 +280,20 @@ def _handle_error(
252280
error: Exception,
253281
) -> EvaluatorResult:
254282
error_detail = str(error)
283+
metadata: dict[str, Any] = {
284+
"error_type": type(error).__name__,
285+
"scorer_label": self.config.scorer_label,
286+
"scorer_id": self.config.scorer_id,
287+
"scorer_version_id": self.config.scorer_version_id,
288+
}
289+
if isinstance(error, httpx.HTTPStatusError):
290+
metadata.update(_http_status_error_metadata(error))
291+
255292
return EvaluatorResult(
256293
matched=False,
257294
confidence=0.0,
258295
message=f"Luna evaluation error: {error_detail}",
259-
metadata={
260-
"error_type": type(error).__name__,
261-
"scorer_label": self.config.scorer_label,
262-
"scorer_id": self.config.scorer_id,
263-
"scorer_version_id": self.config.scorer_version_id,
264-
},
296+
metadata=metadata,
265297
error=error_detail,
266298
)
267299

0 commit comments

Comments
 (0)