Skip to content

Commit 8bc76a0

Browse files
committed
fix: tighten template control validation ergonomics
1 parent 0773358 commit 8bc76a0

7 files changed

Lines changed: 217 additions & 15 deletions

File tree

‎models/src/agent_control_models/controls.py‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import re
66
from collections.abc import Iterator
77
from dataclasses import dataclass
8-
from typing import Annotated, Any, Literal, Self, cast
8+
from typing import Annotated, Any, Literal, Self
99
from uuid import uuid4
1010

1111
import re2
@@ -871,13 +871,6 @@ def observability_identity(self) -> ControlObservabilityIdentity:
871871
all_selector_paths=all_selector_paths,
872872
)
873873

874-
def to_control_definition(self) -> ControlDefinition:
875-
"""Promote a runtime control to the full public model when needed."""
876-
return ControlDefinition.model_validate(
877-
cast(dict[str, JSONValue], self.model_dump(mode="python"))
878-
)
879-
880-
881874
class EvaluatorResult(BaseModel):
882875
"""Result from a control evaluator.
883876

‎models/src/agent_control_models/server.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ def _strip_slug_name(v: str) -> str:
1616

1717
_CONTROL_DEFINITION_ADAPTER = TypeAdapter(ControlDefinition)
1818
_TEMPLATE_CONTROL_INPUT_ADAPTER = TypeAdapter(TemplateControlInput)
19+
_RAW_CONTROL_INPUT_FIELDS = frozenset(
20+
{
21+
"description",
22+
"enabled",
23+
"execution",
24+
"scope",
25+
"condition",
26+
"action",
27+
"tags",
28+
# Legacy flat leaf fields still accepted for raw controls.
29+
"selector",
30+
"evaluator",
31+
}
32+
)
1933

2034

2135
def _parse_control_input(v: Any) -> Any:
@@ -30,6 +44,12 @@ def _parse_control_input(v: Any) -> Any:
3044
return v
3145

3246
if v.get("template") is not None:
47+
mixed_fields = sorted(field for field in v if field in _RAW_CONTROL_INPUT_FIELDS)
48+
if mixed_fields:
49+
raise ValueError(
50+
"Template-backed control input cannot mix template fields with rendered control "
51+
f"fields. Remove raw fields: {', '.join(mixed_fields)}."
52+
)
3353
return _TEMPLATE_CONTROL_INPUT_ADAPTER.validate_python(v)
3454
return _CONTROL_DEFINITION_ADAPTER.validate_python(v)
3555

‎models/tests/test_control_templates.py‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,28 @@ def test_control_definition_requires_template_fields_together() -> None:
5858
)
5959

6060

61+
def test_control_definition_rejects_template_values_without_template() -> None:
62+
with pytest.raises(
63+
ValidationError,
64+
match="template and template_values must both be present or both absent",
65+
):
66+
ControlDefinition.model_validate(
67+
{
68+
"execution": "server",
69+
"scope": {"step_types": ["llm"], "stages": ["pre"]},
70+
"condition": {
71+
"selector": {"path": "input"},
72+
"evaluator": {
73+
"name": "regex",
74+
"config": {"pattern": "ok"},
75+
},
76+
},
77+
"action": {"decision": "deny"},
78+
"template_values": {"pattern": "hello"},
79+
}
80+
)
81+
82+
6183
def test_template_definition_rejects_invalid_parameter_name() -> None:
6284
with pytest.raises(
6385
ValidationError,
@@ -91,7 +113,13 @@ def test_create_control_request_parses_template_payload_as_template_input() -> N
91113

92114

93115
def test_create_control_request_rejects_mixed_raw_and_template_payload() -> None:
94-
with pytest.raises(ValidationError):
116+
with pytest.raises(
117+
ValidationError,
118+
match=(
119+
"Template-backed control input cannot mix template fields with rendered "
120+
"control fields"
121+
),
122+
):
95123
CreateControlRequest.model_validate(
96124
{
97125
"name": "template-control",
@@ -130,6 +158,26 @@ def test_control_definition_can_round_trip_to_template_control_input() -> None:
130158
assert template_input.template_values == {"pattern": "hello"}
131159

132160

161+
def test_control_definition_to_template_control_input_rejects_raw_control() -> None:
162+
control = ControlDefinition.model_validate(
163+
{
164+
"execution": "server",
165+
"scope": {"step_types": ["llm"], "stages": ["pre"]},
166+
"condition": {
167+
"selector": {"path": "input"},
168+
"evaluator": {
169+
"name": "regex",
170+
"config": {"pattern": "hello"},
171+
},
172+
},
173+
"action": {"decision": "deny"},
174+
}
175+
)
176+
177+
with pytest.raises(ValueError, match="not template-backed"):
178+
control.to_template_control_input()
179+
180+
133181
def test_control_definition_runtime_ignores_template_metadata() -> None:
134182
runtime_control = ControlDefinitionRuntime.model_validate(
135183
{

‎sdks/python/tests/test_controls_api.py‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,90 @@ async def test_render_control_template_calls_preview_endpoint() -> None:
109109
)
110110

111111

112+
@pytest.mark.asyncio
113+
async def test_validate_control_data_accepts_template_control_input() -> None:
114+
response = Mock()
115+
response.raise_for_status = Mock()
116+
response.json = Mock(return_value={"success": True})
117+
client = SimpleNamespace(http_client=SimpleNamespace(post=AsyncMock(return_value=response)))
118+
template_input = TemplateControlInput.model_validate(
119+
{
120+
"template": {
121+
"parameters": {
122+
"pattern": {
123+
"type": "regex_re2",
124+
"label": "Pattern",
125+
}
126+
},
127+
"definition_template": {
128+
"execution": "server",
129+
"scope": {"step_types": ["llm"], "stages": ["pre"]},
130+
"condition": {
131+
"selector": {"path": "input"},
132+
"evaluator": {
133+
"name": "regex",
134+
"config": {"pattern": {"$param": "pattern"}},
135+
},
136+
},
137+
"action": {"decision": "deny"},
138+
},
139+
},
140+
"template_values": {"pattern": "hello"},
141+
}
142+
)
143+
144+
await agent_control.controls.validate_control_data(client, template_input)
145+
146+
client.http_client.post.assert_awaited_once()
147+
_, kwargs = client.http_client.post.await_args
148+
assert kwargs["json"]["data"]["template_values"]["pattern"] == "hello"
149+
assert kwargs["json"] == {
150+
"data": {
151+
"template": kwargs["json"]["data"]["template"],
152+
"template_values": {"pattern": "hello"},
153+
}
154+
}
155+
156+
157+
@pytest.mark.asyncio
158+
async def test_set_control_data_accepts_template_control_input() -> None:
159+
response = Mock()
160+
response.raise_for_status = Mock()
161+
response.json = Mock(return_value={"success": True})
162+
client = SimpleNamespace(http_client=SimpleNamespace(put=AsyncMock(return_value=response)))
163+
template_input = TemplateControlInput.model_validate(
164+
{
165+
"template": {
166+
"parameters": {
167+
"pattern": {
168+
"type": "regex_re2",
169+
"label": "Pattern",
170+
}
171+
},
172+
"definition_template": {
173+
"execution": "server",
174+
"scope": {"step_types": ["llm"], "stages": ["pre"]},
175+
"condition": {
176+
"selector": {"path": "input"},
177+
"evaluator": {
178+
"name": "regex",
179+
"config": {"pattern": {"$param": "pattern"}},
180+
},
181+
},
182+
"action": {"decision": "deny"},
183+
},
184+
},
185+
"template_values": {"pattern": "hello"},
186+
}
187+
)
188+
189+
await agent_control.controls.set_control_data(client, 123, template_input)
190+
191+
client.http_client.put.assert_awaited_once()
192+
_, kwargs = client.http_client.put.await_args
193+
assert kwargs["json"]["data"]["template_values"]["pattern"] == "hello"
194+
195+
112196
def test_to_template_control_input_reshapes_stored_control_data() -> None:
113197
template_input = agent_control.controls.to_template_control_input(
114198
{
@@ -148,3 +232,21 @@ def test_to_template_control_input_reshapes_stored_control_data() -> None:
148232

149233
assert isinstance(template_input, TemplateControlInput)
150234
assert template_input.template_values == {"pattern": "hello"}
235+
236+
237+
def test_to_template_control_input_rejects_raw_control_data() -> None:
238+
with pytest.raises(ValueError, match="not template-backed"):
239+
agent_control.controls.to_template_control_input(
240+
{
241+
"execution": "server",
242+
"scope": {"step_types": ["llm"], "stages": ["pre"]},
243+
"condition": {
244+
"selector": {"path": "input"},
245+
"evaluator": {
246+
"name": "regex",
247+
"config": {"pattern": "hello"},
248+
},
249+
},
250+
"action": {"decision": "deny"},
251+
}
252+
)

‎server/src/agent_control_server/endpoints/controls.py‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -528,11 +528,9 @@ async def set_control_data(
528528
current_template_backed = _is_template_backed_payload(control.data)
529529
if isinstance(request.data, TemplateControlInput):
530530
current_enabled = True
531-
if control.data:
532-
try:
533-
current_enabled = ControlDefinition.model_validate(control.data).enabled
534-
except ValidationError:
535-
current_enabled = True
531+
if isinstance(control.data, dict):
532+
raw_enabled = control.data.get("enabled", True)
533+
current_enabled = raw_enabled if type(raw_enabled) is bool else True
536534
control_def = await _render_and_validate_template_input(
537535
request.data,
538536
db=db,

‎server/src/agent_control_server/services/control_templates.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,27 @@ def _render_json_value(
297297
message=f"Template references undefined parameter '{parameter_name}'.",
298298
)
299299

300+
parameter_definition = template.parameters[parameter_name]
301+
if (
302+
not parameter_definition.required
303+
and _parameter_default(parameter_definition) is _TEMPLATE_VALUE_MISSING
304+
):
305+
raise _render_error(
306+
detail=(
307+
f"Template parameter '{parameter_name}' is optional but referenced in "
308+
"definition_template without a default value"
309+
),
310+
field=f"template.parameters.{parameter_name}",
311+
code="optional_referenced_parameter_requires_default",
312+
message=(
313+
f"Optional template parameter '{parameter_definition.label}' is "
314+
"referenced in the template and must define a default value or be "
315+
"marked required."
316+
),
317+
)
318+
300319
resolved_value = resolved_values[parameter_name]
301320
if resolved_value is _TEMPLATE_VALUE_MISSING:
302-
parameter_definition = template.parameters[parameter_name]
303321
raise _parameter_error(
304322
parameter_name,
305323
parameter_definition,

‎server/tests/test_control_templates.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,29 @@ def test_render_control_template_maps_invalid_regex_parameter(client: TestClient
669669
)
670670

671671

672+
def test_render_control_template_rejects_optional_referenced_parameter_without_default(
673+
client: TestClient,
674+
) -> None:
675+
payload = _template_payload()
676+
payload["template"] = deepcopy(payload["template"])
677+
payload["template"]["parameters"]["step_name"] = { # type: ignore[index]
678+
"type": "string",
679+
"label": "Step Name",
680+
"required": False,
681+
}
682+
683+
response = client.post("/api/v1/control-templates/render", json=payload)
684+
685+
assert response.status_code == 422
686+
body = response.json()
687+
assert body["error_code"] == "TEMPLATE_RENDER_ERROR"
688+
assert any(
689+
err.get("field") == "template.parameters.step_name"
690+
and err.get("code") == "optional_referenced_parameter_requires_default"
691+
for err in body.get("errors", [])
692+
)
693+
694+
672695
def test_render_control_template_rejects_malformed_param_binding(client: TestClient) -> None:
673696
payload = _template_payload()
674697
payload["template"] = deepcopy(payload["template"])

0 commit comments

Comments
 (0)