Skip to content

Commit 1dfb7f5

Browse files
committed
fix: wire runtime parsing and template round-tripping
1 parent 41ccd26 commit 1dfb7f5

11 files changed

Lines changed: 319 additions & 60 deletions

File tree

‎engine/src/agent_control_engine/core.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from agent_control_models import (
1717
ConditionNode,
1818
ControlDefinition,
19+
ControlDefinitionRuntime,
1920
ControlMatch,
2021
EvaluationRequest,
2122
EvaluationResponse,
@@ -47,7 +48,7 @@ class ControlWithIdentity(Protocol):
4748

4849
id: int
4950
name: str
50-
control: ControlDefinition
51+
control: ControlDefinition | ControlDefinitionRuntime
5152

5253

5354
@dataclass

‎models/src/agent_control_models/controls.py‎

Lines changed: 76 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,24 @@ class ControlAction(BaseModel):
448448
MAX_CONDITION_DEPTH = 6
449449

450450

451+
def _validate_common_control_constraints(
452+
condition: ConditionNode,
453+
action: ControlAction,
454+
) -> None:
455+
"""Validate control constraints shared by authoring and runtime models."""
456+
if condition.max_depth() > MAX_CONDITION_DEPTH:
457+
raise ValueError(
458+
f"Condition nesting depth exceeds maximum of {MAX_CONDITION_DEPTH}"
459+
)
460+
461+
if (
462+
action.decision == "steer"
463+
and not condition.is_leaf()
464+
and action.steering_context is None
465+
):
466+
raise ValueError("Composite steer controls require action.steering_context")
467+
468+
451469
class ConditionNode(BaseModel):
452470
"""Recursive boolean condition tree for control evaluation."""
453471

@@ -689,19 +707,7 @@ def canonicalize_legacy_condition_shape(cls, data: Any) -> Any:
689707
@model_validator(mode="after")
690708
def validate_condition_constraints(self) -> Self:
691709
"""Validate cross-field control constraints."""
692-
if self.condition.max_depth() > MAX_CONDITION_DEPTH:
693-
raise ValueError(
694-
f"Condition nesting depth exceeds maximum of {MAX_CONDITION_DEPTH}"
695-
)
696-
697-
if (
698-
self.action.decision == "steer"
699-
and not self.condition.is_leaf()
700-
and self.action.steering_context is None
701-
):
702-
raise ValueError(
703-
"Composite steer controls require action.steering_context"
704-
)
710+
_validate_common_control_constraints(self.condition, self.action)
705711
has_template = self.template is not None
706712
has_template_values = self.template_values is not None
707713
if has_template != has_template_values:
@@ -710,6 +716,15 @@ def validate_condition_constraints(self) -> Self:
710716
)
711717
return self
712718

719+
def to_template_control_input(self) -> TemplateControlInput:
720+
"""Extract template-backed authoring input from a stored control definition."""
721+
if self.template is None or self.template_values is None:
722+
raise ValueError("Control definition is not template-backed")
723+
return TemplateControlInput(
724+
template=self.template,
725+
template_values=dict(self.template_values),
726+
)
727+
713728
def iter_condition_leaves(self) -> Iterator[ConditionNode]:
714729
"""Yield leaf conditions in evaluation order."""
715730
yield from self.condition.iter_leaves()
@@ -808,6 +823,54 @@ class ControlDefinitionRuntime(BaseModel):
808823
action: ControlAction = Field(..., description="What action to take when control matches")
809824
tags: list[str] = Field(default_factory=list, description="Tags for categorization")
810825

826+
@model_validator(mode="before")
827+
@classmethod
828+
def canonicalize_legacy_condition_shape(cls, data: Any) -> Any:
829+
"""Accept legacy flat leaf payloads during runtime parsing."""
830+
return ControlDefinition.canonicalize_payload(data)
831+
832+
@model_validator(mode="after")
833+
def validate_condition_constraints(self) -> Self:
834+
"""Validate runtime-relevant control constraints."""
835+
_validate_common_control_constraints(self.condition, self.action)
836+
return self
837+
838+
def iter_condition_leaves(self) -> Iterator[ConditionNode]:
839+
"""Yield leaf conditions in evaluation order."""
840+
yield from self.condition.iter_leaves()
841+
842+
def iter_condition_leaf_parts(self) -> Iterator[ConditionLeafParts]:
843+
"""Yield leaf selector/evaluator pairs in evaluation order."""
844+
yield from self.condition.iter_leaf_parts()
845+
846+
def observability_identity(self) -> ControlObservabilityIdentity:
847+
"""Return a deterministic representative identity for observability."""
848+
all_evaluators: list[str] = []
849+
all_selector_paths: list[str] = []
850+
seen_evaluators: set[str] = set()
851+
seen_selector_paths: set[str] = set()
852+
leaf_count = 0
853+
854+
for selector, evaluator in self.iter_condition_leaf_parts():
855+
leaf_count += 1
856+
selector_path = selector.path or "*"
857+
858+
if evaluator.name not in seen_evaluators:
859+
seen_evaluators.add(evaluator.name)
860+
all_evaluators.append(evaluator.name)
861+
862+
if selector_path not in seen_selector_paths:
863+
seen_selector_paths.add(selector_path)
864+
all_selector_paths.append(selector_path)
865+
866+
return ControlObservabilityIdentity(
867+
selector_path=all_selector_paths[0] if all_selector_paths else None,
868+
evaluator_name=all_evaluators[0] if all_evaluators else None,
869+
leaf_count=leaf_count,
870+
all_evaluators=all_evaluators,
871+
all_selector_paths=all_selector_paths,
872+
)
873+
811874
def to_control_definition(self) -> ControlDefinition:
812875
"""Promote a runtime control to the full public model when needed."""
813876
return ControlDefinition.model_validate(

‎models/tests/test_control_templates.py‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
from __future__ import annotations
44

55
import pytest
6-
from agent_control_models import ControlDefinition, TemplateControlInput, TemplateDefinition
6+
from agent_control_models import (
7+
ControlDefinition,
8+
ControlDefinitionRuntime,
9+
TemplateControlInput,
10+
TemplateDefinition,
11+
)
712
from agent_control_models.server import CreateControlRequest
813
from pydantic import ValidationError
914

@@ -97,3 +102,51 @@ def test_create_control_request_rejects_mixed_raw_and_template_payload() -> None
97102
},
98103
}
99104
)
105+
106+
107+
def test_control_definition_can_round_trip_to_template_control_input() -> None:
108+
control = ControlDefinition.model_validate(
109+
{
110+
"execution": "server",
111+
"scope": {"step_types": ["llm"], "stages": ["pre"]},
112+
"condition": {
113+
"selector": {"path": "input"},
114+
"evaluator": {
115+
"name": "regex",
116+
"config": {"pattern": "hello"},
117+
},
118+
},
119+
"action": {"decision": "deny"},
120+
"template": VALID_TEMPLATE,
121+
"template_values": {"pattern": "hello"},
122+
}
123+
)
124+
125+
template_input = control.to_template_control_input()
126+
127+
assert template_input.template.parameters["pattern"].label == "Pattern"
128+
assert template_input.template.parameters["pattern"].type == "regex_re2"
129+
assert template_input.template.definition_template == VALID_TEMPLATE["definition_template"]
130+
assert template_input.template_values == {"pattern": "hello"}
131+
132+
133+
def test_control_definition_runtime_ignores_template_metadata() -> None:
134+
runtime_control = ControlDefinitionRuntime.model_validate(
135+
{
136+
"execution": "server",
137+
"scope": {"step_types": ["llm"], "stages": ["pre"]},
138+
"condition": {
139+
"selector": {"path": "input"},
140+
"evaluator": {
141+
"name": "regex",
142+
"config": {"pattern": "hello"},
143+
},
144+
},
145+
"action": {"decision": "deny"},
146+
"template": VALID_TEMPLATE,
147+
"template_values": {"pattern": "hello"},
148+
}
149+
)
150+
151+
assert runtime_control.execution == "server"
152+
assert runtime_control.action.decision == "deny"

‎sdks/python/src/agent_control/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,13 @@ def get_server_controls() -> list[dict[str, Any]] | None:
164164
return state.server_controls
165165

166166

167+
def to_template_control_input(
168+
data: dict[str, Any] | ControlDefinition,
169+
) -> TemplateControlInput:
170+
"""Convert stored control data into template authoring input."""
171+
return controls.to_template_control_input(data)
172+
173+
167174
def _publish_server_controls(
168175
controls: list[dict[str, Any]] | None,
169176
) -> list[dict[str, Any]] | None:
@@ -1316,6 +1323,7 @@ async def main():
13161323
"update_control",
13171324
"validate_control_data",
13181325
"render_control_template",
1326+
"to_template_control_input",
13191327
# Decorator
13201328
"control",
13211329
"ControlViolationError",

‎sdks/python/src/agent_control/controls.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,22 @@ async def render_control_template(
265265
return cast(dict[str, Any], response.json())
266266

267267

268+
def to_template_control_input(
269+
data: dict[str, Any] | ControlDefinition,
270+
) -> TemplateControlInput:
271+
"""Convert stored control data into template authoring input.
272+
273+
This is the supported reshape path for template-backed controls returned by
274+
``GET /controls/{id}`` or ``GET /controls/{id}/data`` before submitting them
275+
back to ``set_control_data``.
276+
"""
277+
if isinstance(data, ControlDefinition):
278+
control_def = data
279+
else:
280+
control_def = ControlDefinition.model_validate(data)
281+
return control_def.to_template_control_input()
282+
283+
268284
async def add_rule_to_control(
269285
client: AgentControlClient,
270286
control_id: int,

‎sdks/python/src/agent_control/evaluation.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from agent_control_engine.core import ControlEngine
99
from agent_control_models import (
1010
ControlDefinition,
11+
ControlDefinitionRuntime,
1112
ControlExecutionEvent,
1213
ControlMatch,
1314
EvaluationRequest,
@@ -33,7 +34,7 @@
3334

3435

3536
def _observability_metadata(
36-
control_def: ControlDefinition,
37+
control_def: ControlDefinition | ControlDefinitionRuntime,
3738
) -> tuple[str | None, str | None, dict[str, object]]:
3839
"""Return representative event fields plus full composite context."""
3940
identity = control_def.observability_identity()
@@ -159,7 +160,7 @@ class _ControlAdapter:
159160

160161
id: int
161162
name: str
162-
control: "ControlDefinition"
163+
control: "ControlDefinition | ControlDefinitionRuntime"
163164

164165

165166
def _get_applicable_controls(
@@ -193,7 +194,7 @@ def _has_applicable_prefiltered_server_controls(
193194

194195
for control in server_control_payloads:
195196
try:
196-
control_def = ControlDefinition.model_validate(control["control"])
197+
control_def = ControlDefinitionRuntime.model_validate(control["control"])
197198
parsed_server_controls.append(
198199
_ControlAdapter(
199200
id=control["id"],
@@ -315,7 +316,7 @@ async def check_evaluation_with_local(
315316
continue
316317

317318
try:
318-
control_def = ControlDefinition.model_validate(control_data)
319+
control_def = ControlDefinitionRuntime.model_validate(control_data)
319320
for _, evaluator_spec in control_def.iter_condition_leaf_parts():
320321
evaluator_name = evaluator_spec.name
321322

‎sdks/python/tests/test_controls_api.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,44 @@ async def test_render_control_template_calls_preview_endpoint() -> None:
107107
"template_values": {},
108108
},
109109
)
110+
111+
112+
def test_to_template_control_input_reshapes_stored_control_data() -> None:
113+
template_input = agent_control.controls.to_template_control_input(
114+
{
115+
"execution": "server",
116+
"scope": {"step_types": ["llm"], "stages": ["pre"]},
117+
"condition": {
118+
"selector": {"path": "input"},
119+
"evaluator": {
120+
"name": "regex",
121+
"config": {"pattern": "hello"},
122+
},
123+
},
124+
"action": {"decision": "deny"},
125+
"template": {
126+
"parameters": {
127+
"pattern": {
128+
"type": "regex_re2",
129+
"label": "Pattern",
130+
}
131+
},
132+
"definition_template": {
133+
"execution": "server",
134+
"scope": {"step_types": ["llm"], "stages": ["pre"]},
135+
"condition": {
136+
"selector": {"path": "input"},
137+
"evaluator": {
138+
"name": "regex",
139+
"config": {"pattern": {"$param": "pattern"}},
140+
},
141+
},
142+
"action": {"decision": "deny"},
143+
},
144+
},
145+
"template_values": {"pattern": "hello"},
146+
}
147+
)
148+
149+
assert isinstance(template_input, TemplateControlInput)
150+
assert template_input.template_values == {"pattern": "hello"}

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

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from collections.abc import Sequence
2-
from typing import Any, Protocol
2+
from typing import Any
33

44
from agent_control_engine import list_evaluators
55
from agent_control_models.agent import Agent as APIAgent
66
from agent_control_models.agent import StepSchema
7-
from agent_control_models.controls import ControlDefinition
7+
from agent_control_models.controls import ControlDefinitionRuntime
88
from agent_control_models.errors import ErrorCode, ValidationErrorItem
9+
from agent_control_models.policy import Control as APIControl
910
from agent_control_models.server import (
1011
AgentControlsResponse,
1112
AgentSummary,
@@ -82,13 +83,6 @@
8283
type StepKeyTuple = tuple[str, str]
8384

8485

85-
class _ControlWithDefinition(Protocol):
86-
"""Minimal control shape needed for evaluator dependency scans."""
87-
88-
name: str
89-
control: ControlDefinition
90-
91-
9286
# =============================================================================
9387
# List Agents Models
9488
# =============================================================================
@@ -119,7 +113,7 @@ def _validate_controls_for_agent(agent: Agent, controls: list[Control]) -> list[
119113
continue
120114

121115
try:
122-
control_definition = ControlDefinition.model_validate(control.data)
116+
control_definition = ControlDefinitionRuntime.model_validate(control.data)
123117
except ValidationError:
124118
errors.append(f"Control '{control.name}' has corrupted data")
125119
continue
@@ -165,7 +159,7 @@ def _validate_controls_for_agent(agent: Agent, controls: list[Control]) -> list[
165159

166160

167161
def _find_referencing_controls_for_removed_evaluators(
168-
controls: Sequence[_ControlWithDefinition],
162+
controls: Sequence[APIControl],
169163
agent_name: str,
170164
remove_evaluator_set: set[str],
171165
) -> list[tuple[str, str]]:

0 commit comments

Comments
 (0)