Skip to content

Commit d2cbffb

Browse files
riolocclaude
authored andcommitted
feat: add agents configuration layer with backward-compatible api: migration (#228)
* feat: add agents configuration layer with backward-compatible api: migration Introduce a generic `agents:` configuration block in system.yaml that makes the HTTP API one agent type among potentially many (e.g., Kubernetes CRD agents for Openshift Agentic Lightspeed). The key design ensures zero breakage of existing configs and code: - New Pydantic models: AgentsConfig, AgentDefaultConfig, HttpApiAgentConfig in a new `core/models/agents.py` module - SystemConfig gains an `agents: Optional[AgentsConfig]` field alongside the existing `api:` field - A model_validator auto-migrates `api:` to `agents:` when `agents:` is absent, so all existing system.yaml files continue working unchanged - `api.enabled=True` maps to `default.agent="http_api"`; `api.enabled=False` maps to `default.agent=None` - The `api` field is preserved — all downstream code reading `config.api` continues to work without modification - EvaluationData gains `agent` and `agent_config` optional fields for per-conversation-group agent selection and config overrides - Config resolution follows a 3-level priority chain: eval_data.agent_config > agents.<name> > agents.default - ConfigLoader passes `agents` YAML data through to SystemConfig Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: address PR #228 review feedback on agents config layer - Replace timeout/retry fields with generic agent_config dict on AgentDefaultConfig and HttpApiAgentConfig, implementing 3-level per-key merge (default < agent < eval_data) in resolve_agent_config - Move enabled from per-agent (HttpApiAgentConfig) to AgentsConfig level, add api_enabled property on SystemConfig for backward-compatible access - Move whitespace pattern validator from EvaluationData.agent to AgentDefaultConfig.agent where agent names are defined as identifiers - Update endpoint_type description to include infer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve default agent config for API client instead of legacy api defaults _create_api_client now calls AgentsConfig.resolve_agent_config() to obtain the correct endpoint_type, api_base, and other settings from the default agent definition. Previously it used config.api directly, which fell back to legacy defaults (endpoint_type: streaming) even when agents were explicitly configured with endpoint_type: query. Widen APIClient config type to accept HttpApiAgentConfig alongside APIConfig since both share the same HttpApiBaseFields interface. Per-conversation agent overrides are not yet supported and will be addressed by the agent driver architecture. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 72eeedd commit d2cbffb

32 files changed

Lines changed: 1684 additions & 1170 deletions

src/lightspeed_evaluation/core/api/client.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import json
55
import logging
66
import os
7-
from typing import Any, Optional, cast
7+
from typing import Any, Optional, Union, cast
88

99
import httpx
1010
from diskcache import Cache
@@ -22,6 +22,7 @@
2222
SUPPORTED_ENDPOINT_TYPES,
2323
)
2424
from lightspeed_evaluation.core.models import APIConfig, APIRequest, APIResponse
25+
from lightspeed_evaluation.core.models.agents import HttpApiAgentConfig
2526
from lightspeed_evaluation.core.system.exceptions import APIError
2627

2728
logger = logging.getLogger(__name__)
@@ -51,7 +52,7 @@ class APIClient:
5152

5253
def __init__(
5354
self,
54-
config: APIConfig,
55+
config: Union[APIConfig, HttpApiAgentConfig],
5556
):
5657
"""Initialize the client with configuration."""
5758
self.config = config

src/lightspeed_evaluation/core/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@
6161

6262
DEFAULT_API_NUM_RETRIES = 3
6363

64+
# Agent Constants
65+
DEFAULT_AGENT_TYPE = "http_api"
66+
SUPPORTED_AGENT_TYPES = ["http_api"]
67+
6468
# Frameworks that don't require judge LLM (NLP, script-based evaluations)
6569
NON_LLM_FRAMEWORKS = frozenset({"nlp", "script"})
6670

src/lightspeed_evaluation/core/models/__init__.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
"""Data models for the evaluation framework."""
22

3+
from lightspeed_evaluation.core.models.agents import (
4+
AgentDefaultConfig,
5+
AgentsConfig,
6+
HttpApiAgentConfig,
7+
MCPHeadersConfig,
8+
MCPServerConfig,
9+
)
310
from lightspeed_evaluation.core.models.api import (
411
APIRequest,
512
APIResponse,
@@ -28,8 +35,26 @@
2835
SystemConfig,
2936
VisualizationConfig,
3037
)
38+
from lightspeed_evaluation.core.models.statistics import (
39+
NumericStats,
40+
ScoreStatistics,
41+
OverallStats,
42+
MetricStats,
43+
ConversationStats,
44+
TagStats,
45+
StreamingStats,
46+
ApiTokenUsage,
47+
ConfidenceInterval,
48+
DetailedStats,
49+
)
3150

3251
__all__ = [
52+
# Agent config models
53+
"AgentDefaultConfig",
54+
"AgentsConfig",
55+
"HttpApiAgentConfig",
56+
"MCPHeadersConfig",
57+
"MCPServerConfig",
3358
# Data models
3459
"TurnData",
3560
"EvaluationData",
@@ -51,6 +76,17 @@
5176
"LoggingConfig",
5277
"SystemConfig",
5378
"VisualizationConfig",
79+
# Stats models
80+
"NumericStats",
81+
"ScoreStatistics",
82+
"OverallStats",
83+
"MetricStats",
84+
"ConversationStats",
85+
"TagStats",
86+
"StreamingStats",
87+
"ApiTokenUsage",
88+
"ConfidenceInterval",
89+
"DetailedStats",
5490
# API models
5591
"APIRequest",
5692
"APIResponse",
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
"""Agent configuration models for the evaluation framework."""
2+
3+
import os
4+
from typing import Any, Literal, Optional, Union
5+
6+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
7+
8+
from lightspeed_evaluation.core.constants import (
9+
DEFAULT_API_BASE,
10+
DEFAULT_API_CACHE_DIR,
11+
DEFAULT_API_NUM_RETRIES,
12+
DEFAULT_API_TIMEOUT,
13+
DEFAULT_API_VERSION,
14+
DEFAULT_ENDPOINT_TYPE,
15+
SUPPORTED_AGENT_TYPES,
16+
SUPPORTED_ENDPOINT_TYPES,
17+
)
18+
from lightspeed_evaluation.core.system.exceptions import ConfigurationError
19+
20+
21+
class MCPServerConfig(BaseModel):
22+
"""Configuration for a single MCP server authentication."""
23+
24+
model_config = ConfigDict(extra="forbid")
25+
26+
env_var: str = Field(
27+
...,
28+
min_length=1,
29+
description="Environment variable containing the token/key",
30+
)
31+
header_name: Optional[str] = Field(
32+
default=None,
33+
description="Custom header name (optional, defaults to 'Authorization')",
34+
)
35+
36+
37+
class MCPHeadersConfig(BaseModel):
38+
"""Configuration for MCP headers functionality."""
39+
40+
model_config = ConfigDict(extra="forbid")
41+
42+
enabled: bool = Field(
43+
default=True,
44+
description="Enable MCP headers functionality",
45+
)
46+
servers: dict[str, MCPServerConfig] = Field(
47+
default_factory=dict,
48+
description="MCP server configurations",
49+
)
50+
51+
@model_validator(mode="after")
52+
def _validate_env_vars_when_enabled(self) -> "MCPHeadersConfig":
53+
"""Validate that environment variables are set when MCP headers are enabled."""
54+
if self.enabled and self.servers:
55+
missing_vars = []
56+
for server_name, server_config in self.servers.items():
57+
if not os.getenv(server_config.env_var):
58+
missing_vars.append(f"{server_name}: {server_config.env_var}")
59+
60+
if missing_vars:
61+
missing_list = ", ".join(missing_vars)
62+
msg = (
63+
"MCP headers are enabled but required environment "
64+
"variables are not set: "
65+
f"{missing_list}"
66+
)
67+
raise ValueError(msg)
68+
69+
return self
70+
71+
72+
class HttpApiBaseFields(BaseModel):
73+
"""Shared HTTP API connection fields.
74+
75+
Base class for both ``HttpApiAgentConfig`` (agents layer) and
76+
``APIConfig`` (legacy api: block) to avoid duplicate field definitions.
77+
"""
78+
79+
model_config = ConfigDict(extra="forbid")
80+
81+
api_base: str = Field(
82+
default=DEFAULT_API_BASE,
83+
description="Base URL for API requests (without version)",
84+
)
85+
version: str = Field(
86+
default=DEFAULT_API_VERSION, description="API version (e.g., v1, v2)"
87+
)
88+
endpoint_type: str = Field(
89+
default=DEFAULT_ENDPOINT_TYPE,
90+
description="API endpoint type (streaming / query / infer)",
91+
)
92+
timeout: int = Field(
93+
default=DEFAULT_API_TIMEOUT, ge=1, description="Request timeout in seconds"
94+
)
95+
provider: Optional[str] = Field(default=None, description="LLM provider for API")
96+
model: Optional[str] = Field(default=None, description="LLM model for API")
97+
no_tools: Optional[bool] = Field(
98+
default=None, description="Disable tool usage in API calls"
99+
)
100+
system_prompt: Optional[str] = Field(
101+
default=None, description="System prompt for API calls"
102+
)
103+
extra_request_params: Optional[dict[str, Any]] = Field(default=None)
104+
cache_dir: str = Field(
105+
default=DEFAULT_API_CACHE_DIR,
106+
min_length=1,
107+
description="Location of cached API queries",
108+
)
109+
cache_enabled: bool = Field(
110+
default=True, description="Is caching of API queries enabled?"
111+
)
112+
num_retries: int = Field(
113+
default=DEFAULT_API_NUM_RETRIES,
114+
ge=0,
115+
description="Maximum number of retry attempts for 429 errors",
116+
)
117+
118+
@field_validator("endpoint_type")
119+
@classmethod
120+
def validate_endpoint_type(cls, v: str) -> str:
121+
"""Validate endpoint type is supported."""
122+
if v not in SUPPORTED_ENDPOINT_TYPES:
123+
raise ValueError(f"Endpoint type must be one of {SUPPORTED_ENDPOINT_TYPES}")
124+
return v
125+
126+
127+
class HttpApiAgentConfig(HttpApiBaseFields):
128+
"""Configuration for an HTTP API agent."""
129+
130+
type: Literal["http_api"] = Field(
131+
default="http_api", description="Agent type identifier"
132+
)
133+
mcp_headers: Optional[MCPHeadersConfig] = Field(
134+
default=None,
135+
description="MCP headers configuration for authentication",
136+
)
137+
138+
139+
# Discriminated union of all agent config types; extend by adding new
140+
# config classes to support additional agent types.
141+
AgentDefinition = Union[HttpApiAgentConfig]
142+
143+
144+
class AgentDefaultConfig(BaseModel):
145+
"""Default agent selection and shared configuration."""
146+
147+
model_config = ConfigDict(extra="forbid")
148+
149+
agent: Optional[str] = Field(
150+
default=None,
151+
min_length=1,
152+
pattern=r"\S",
153+
description="Name of the default agent when eval_data doesn't specify one",
154+
)
155+
agent_config: Optional[dict[str, Any]] = Field(
156+
default=None,
157+
description="Shared default agent config overrides applied to all agents",
158+
)
159+
160+
161+
class AgentsConfig(BaseModel):
162+
"""Top-level agents configuration container.
163+
164+
Parses a flat YAML namespace where ``default`` is a reserved key holding
165+
shared config and agent selection, and all other keys with a ``type`` field
166+
are agent definitions.
167+
"""
168+
169+
model_config = ConfigDict(extra="forbid")
170+
171+
enabled: bool = Field(
172+
default=True,
173+
description="Enable agent-based API calls instead of using pre-filled data",
174+
)
175+
default: AgentDefaultConfig = Field(default_factory=AgentDefaultConfig)
176+
agents: dict[str, AgentDefinition] = Field(default_factory=dict)
177+
178+
@model_validator(mode="before")
179+
@classmethod
180+
def extract_agent_definitions(cls, data: Any) -> Any:
181+
"""Extract named agent definitions from the flat YAML namespace."""
182+
if not isinstance(data, dict):
183+
return data
184+
185+
data = dict(data)
186+
agents: dict[str, Any] = {}
187+
default_data = data.pop("default", {})
188+
remaining: dict[str, Any] = {}
189+
190+
for key, value in data.items():
191+
if key == "agents":
192+
agents.update(value)
193+
elif isinstance(value, dict) and "type" in value:
194+
agents[key] = value
195+
else:
196+
remaining[key] = value
197+
198+
result: dict[str, Any] = {"default": default_data, "agents": agents}
199+
result.update(remaining)
200+
return result
201+
202+
@model_validator(mode="after")
203+
def validate_agent_types(self) -> "AgentsConfig":
204+
"""Validate that all agent definitions have supported types."""
205+
for name, agent_def in self.agents.items():
206+
if agent_def.type not in SUPPORTED_AGENT_TYPES:
207+
raise ConfigurationError(
208+
f"Agent '{name}' has unsupported type '{agent_def.type}'. "
209+
f"Supported types: {SUPPORTED_AGENT_TYPES}"
210+
)
211+
return self
212+
213+
def resolve_agent_config(
214+
self,
215+
agent_name: Optional[str] = None,
216+
agent_config_override: Optional[dict[str, Any]] = None,
217+
) -> tuple[str, dict[str, Any]]:
218+
"""Resolve final agent configuration from the 2-level priority chain.
219+
220+
Per-key merge in ascending priority order — higher levels override
221+
matching keys while non-overlapping keys from lower levels survive:
222+
223+
1. ``default.agent_config`` (lowest)
224+
2. ``agent_config_override`` from eval data (highest)
225+
226+
The agent definition's typed fields form the base; override dicts
227+
are applied on top.
228+
229+
Args:
230+
agent_name: Explicit agent name. Falls back to default.agent.
231+
agent_config_override: Per-evaluation config overrides (highest priority).
232+
233+
Returns:
234+
Tuple of (agent_name, merged_config_dict).
235+
236+
Raises:
237+
ConfigurationError: If no agent can be resolved or agent not found.
238+
"""
239+
name = agent_name or self.default.agent
240+
if name is None:
241+
raise ConfigurationError(
242+
"No agent specified and no default agent configured"
243+
)
244+
245+
if name not in self.agents:
246+
raise ConfigurationError(
247+
f"Agent '{name}' not found in agents configuration. "
248+
f"Available agents: {list(self.agents.keys())}"
249+
)
250+
251+
agent_def = self.agents[name]
252+
253+
effective: dict[str, Any] = {}
254+
if self.default.agent_config:
255+
effective.update(self.default.agent_config)
256+
if agent_config_override:
257+
effective.update(agent_config_override)
258+
259+
base_config = agent_def.model_dump()
260+
base_config.update(effective)
261+
262+
return name, base_config

src/lightspeed_evaluation/core/models/data.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,17 @@ class EvaluationData(BaseModel):
409409
description="Skip remaining turns when a turn evaluation fails (overrides system config)",
410410
)
411411

412+
# Agent selection and config override
413+
agent: Optional[str] = Field(
414+
default=None,
415+
min_length=1,
416+
description="Agent name for this conversation group (overrides agents.default.agent)",
417+
)
418+
agent_config: Optional[dict[str, Any]] = Field(
419+
default=None,
420+
description="Per-conversation agent config overrides (highest priority)",
421+
)
422+
412423
# Set of conversation metrics that don't pass the validation to ignore them later
413424
_invalid_metrics: set[str] = set()
414425

src/lightspeed_evaluation/core/models/quality.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from pydantic import BaseModel, Field
1111

12-
from lightspeed_evaluation.core.models.summary import MetricStats, ScoreStatistics
12+
from lightspeed_evaluation.core.models import MetricStats, ScoreStatistics
1313

1414
logger = logging.getLogger(__name__)
1515

0 commit comments

Comments
 (0)