Skip to content

Commit 78bb538

Browse files
authored
feat(server,sdk, ui): Control Templates (#158)
## What this does Today, creating or updating a control requires hand-editing the full control JSON — condition trees, evaluator configs, selector paths, and all. Templates let callers define a reusable control shape with named, typed parameters, and then create or update controls by filling in just the parameter values. For example, instead of constructing an entire regex-denial control from scratch, a caller can submit a template with a `pattern` parameter and a `step_name` parameter, provide values for those parameters, and get a fully rendered, validated control back. Template controls can also be created **without parameter values** (unrendered templates). This supports a "set up now, configure later" workflow — the template is attached to an agent but excluded from evaluation until values are provided. **RFC and implementation plan:** https://gist.github.com/lan17/ea9aaca990c9bcbfda6595469f3e76c5 ## How it works Templates use a **render-before-save** design. The caller sends a `TemplateDefinition` (parameter schema + a `definition_template` with `{"$param": "..."}` placeholders) and `template_values`. The server substitutes the values, validates the result as an ordinary `ControlDefinition`, and stores both the rendered control and the template metadata in the same `controls.data` JSONB column. No schema migration needed. ``` ┌─────────────────────┐ TemplateDefinition ──▶ │ Server-side render │ ──▶ ControlDefinition + parameter values │ (validate, coerce, │ (concrete, stored │ substitute $param) │ in controls.data) └─────────────────────┘ │ ▼ ┌──────────────┐ │ Evaluation │ │ engine │ │ (reads only │ │ concrete │ │ fields) │ └──────────────┘ ``` A template control exists in one of two states: - **Rendered** — parameter values are complete, the template has been rendered into a concrete `ControlDefinition`, and the control is ready for evaluation (once enabled). - **Unrendered** — the template definition is stored, but parameter values are missing or incomplete. The control is visible and attachable to agents, but forced `enabled: false` and excluded from evaluation. The evaluation engine never sees template metadata or unrendered templates. Rendered template controls use `ControlDefinitionRuntime` with `extra="ignore"` to skip template fields. Unrendered templates are filtered from runtime queries via `data ? 'condition'`. **Key design decisions:** - **No new CRUD endpoints for controls.** Existing create (`PUT /controls`) and update (`PUT /controls/{id}/data`) endpoints detect template payloads via a `ControlDefinition | TemplateControlInput` union and render transparently. One new endpoint (`POST /control-templates/render`) provides stateless previews. - **Unrendered templates are first-class.** Creating a template control with empty `template_values` stores an unrendered template (`enabled: false`). The server validates template structure (parameter references, forbidden fields, agent-scoped evaluators) but skips rendering. Partial values are type-checked on create. - **`enabled` and `name` stay outside the template.** Templates cannot set or bind these fields. `enabled` is managed via `PATCH` and preserved across template updates. Enabling an unrendered template is rejected with 422. - **Template-backed controls cannot be converted back to raw controls in v1.** `PUT /data` with a raw `ControlDefinition` on a template-backed control returns 409. - **Validation errors map back to template parameters.** When a rendered control fails validation, the server traces the error back through a reverse path map to the originating `$param` binding. - **GET responses use a union type.** `GET /controls/{id}/data` returns `ControlDefinition` for rendered controls or `UnrenderedTemplateControl` for unrendered templates. - **List filters exclude unrendered templates** when filtering by rendered-only fields (execution, step_type, stage, tag). Unrendered templates appear in unfiltered listings and the `template_backed` filter. - **SDK evaluation skips unrendered templates** in `check_evaluation_with_local` to avoid triggering server-call fallbacks. ## Reviewer guide **Start here** — these tests show the full lifecycle: 1. `test_render_control_template_preview_returns_rendered_control` — preview a template without persisting 2. `test_create_template_backed_control_persists_template_metadata` — create rendered and verify stored state 3. `test_create_unrendered_template_control_without_values` — create without values and verify unrendered state 4. `test_update_unrendered_template_with_complete_values_renders` — provide values to render an unrendered template 5. `test_template_backed_control_evaluates_after_policy_attachment` — attach to agent and verify evaluation 6. `test_unrendered_template_excluded_from_evaluation` — verify unrendered templates don't affect evaluation **Then follow by layer:** | Layer | Key files | What to look for | |-------|-----------|-----------------| | **Shared models** | `models/.../controls.py` | Template types, `UnrenderedTemplateControl`, `_ConditionBackedControlMixin`, `ControlDefinition` extension, `ControlDefinitionRuntime` | | **Payload discrimination** | `models/.../server.py` | `_parse_control_input` — discriminates raw vs template payloads, rejects mixed payloads. Response unions for `GetControlResponse`, `GetControlDataResponse` | | **Rendering service** | `server/.../services/control_templates.py` | `can_render_template`, `validate_template_structure`, `validate_partial_template_values`, `render_template_control_input`, reverse path map, error remapping | | **Endpoints** | `server/.../endpoints/controls.py` | `_materialize_control_input` (rendered vs unrendered branching), PATCH handler (enable guard), list filters, `_parse_stored_control_data` union | | **Runtime split** | `server/.../services/controls.py`, `engine/.../core.py` | `ControlDefinitionRuntime` wired into evaluation, unrendered templates skipped in runtime and agent-controls queries | | **Python SDK** | `sdks/python/.../controls.py`, `.../evaluation.py` | `to_template_control_input()` handles both rendered and unrendered shapes. `check_evaluation_with_local` skips unrendered templates | ## V1 limitations - **No agent-scoped evaluators in templates** — rejected during both structural validation and rendering - **No in-place template-to-raw conversion** — delete and recreate to convert - **No `$param` escaping** — the `$param` key is reserved in all template JSON values - **No string interpolation** — `$param` replaces the entire JSON value, not a substring - **No template catalogs** — callers supply the template definition on each request - **Last-write-wins concurrency** — no optimistic locking in v1 - **Read/write asymmetry** — GET returns rendered fields + template metadata, but PUT expects `TemplateControlInput` only (no rendered fields). Use `to_template_control_input()` SDK helper to reshape. ## Validation - `make check` (lint + typecheck + all tests) - `make sdk-ts-generate` + `make sdk-ts-name-check` + `make sdk-ts-typecheck` + `make sdk-ts-build`
1 parent 2186ba1 commit 78bb538

File tree

86 files changed

+9669
-543
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

86 files changed

+9669
-543
lines changed

engine/src/agent_control_engine/core.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@
1515
from agent_control_evaluators import get_evaluator_instance
1616
from agent_control_models import (
1717
ConditionNode,
18-
ControlDefinition,
18+
ControlAction,
1919
ControlMatch,
20+
ControlScope,
2021
EvaluationRequest,
2122
EvaluationResponse,
2223
EvaluatorResult,
@@ -42,12 +43,30 @@ def _compile_regex(pattern: str) -> Any:
4243
return re2.compile(pattern)
4344

4445

46+
class ControlDefinitionLike(Protocol):
47+
"""Runtime control shape required by the engine."""
48+
49+
enabled: bool
50+
execution: Literal["server", "sdk"]
51+
scope: ControlScope
52+
condition: ConditionNode
53+
action: ControlAction
54+
55+
4556
class ControlWithIdentity(Protocol):
4657
"""Protocol for a control with identity information."""
4758

48-
id: int
49-
name: str
50-
control: ControlDefinition
59+
@property
60+
def id(self) -> int:
61+
"""Database identity for the control."""
62+
63+
@property
64+
def name(self) -> str:
65+
"""Human-readable name for the control."""
66+
67+
@property
68+
def control(self) -> ControlDefinitionLike:
69+
"""Runtime control payload used during evaluation."""
5170

5271

5372
@dataclass

models/src/agent_control_models/__init__.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,28 @@
2424
StepSchema,
2525
)
2626
from .controls import (
27+
BooleanTemplateParameter,
2728
ConditionNode,
2829
ControlAction,
2930
ControlDefinition,
31+
ControlDefinitionRuntime,
3032
ControlMatch,
3133
ControlScope,
3234
ControlSelector,
35+
EnumTemplateParameter,
3336
EvaluatorResult,
3437
EvaluatorSpec,
38+
JsonValue,
39+
RegexTemplateParameter,
3540
SteeringContext,
41+
StringListTemplateParameter,
42+
StringTemplateParameter,
43+
TemplateControlInput,
44+
TemplateDefinition,
45+
TemplateParameterBase,
46+
TemplateParameterDefinition,
47+
TemplateValue,
48+
UnrenderedTemplateControl,
3649
)
3750
from .errors import (
3851
ERROR_TITLES,
@@ -80,6 +93,8 @@
8093
PaginationInfo,
8194
PatchControlRequest,
8295
PatchControlResponse,
96+
RenderControlTemplateRequest,
97+
RenderControlTemplateResponse,
8398
StepKey,
8499
ValidateControlDataRequest,
85100
ValidateControlDataResponse,
@@ -111,9 +126,22 @@
111126
"ControlMatch",
112127
"ControlScope",
113128
"ControlSelector",
129+
"ControlDefinitionRuntime",
114130
"EvaluatorSpec",
115131
"EvaluatorResult",
116132
"SteeringContext",
133+
"JsonValue",
134+
"TemplateValue",
135+
"TemplateParameterBase",
136+
"StringTemplateParameter",
137+
"StringListTemplateParameter",
138+
"EnumTemplateParameter",
139+
"BooleanTemplateParameter",
140+
"RegexTemplateParameter",
141+
"TemplateParameterDefinition",
142+
"TemplateDefinition",
143+
"TemplateControlInput",
144+
"UnrenderedTemplateControl",
117145
"normalize_action",
118146
"normalize_action_list",
119147
"expand_action_filter",
@@ -142,6 +170,8 @@
142170
"PaginationInfo",
143171
"PatchControlRequest",
144172
"PatchControlResponse",
173+
"RenderControlTemplateRequest",
174+
"RenderControlTemplateResponse",
145175
"StepKey",
146176
"ValidateControlDataRequest",
147177
"ValidateControlDataResponse",

0 commit comments

Comments
 (0)