Commit 78bb538
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- engine/src/agent_control_engine
- models
- src/agent_control_models
- tests
- sdks
- python
- src/agent_control
- tests
- typescript
- overlays
- src/generated
- funcs
- models
- operations
- sdk
- types
- server
- src/agent_control_server
- endpoints
- services
- tests
- ui
- src
- components/json-editor-monaco
- core
- api
- components
- hooks/query-hooks
- layouts
- page-components/agent-detail
- controls
- modals
- add-new-control
- create-from-template
- edit-control
- tests
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| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
18 | | - | |
| 18 | + | |
19 | 19 | | |
| 20 | + | |
20 | 21 | | |
21 | 22 | | |
22 | 23 | | |
| |||
42 | 43 | | |
43 | 44 | | |
44 | 45 | | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
45 | 56 | | |
46 | 57 | | |
47 | 58 | | |
48 | | - | |
49 | | - | |
50 | | - | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
51 | 70 | | |
52 | 71 | | |
53 | 72 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
| 27 | + | |
27 | 28 | | |
28 | 29 | | |
29 | 30 | | |
| 31 | + | |
30 | 32 | | |
31 | 33 | | |
32 | 34 | | |
| 35 | + | |
33 | 36 | | |
34 | 37 | | |
| 38 | + | |
| 39 | + | |
35 | 40 | | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
36 | 49 | | |
37 | 50 | | |
38 | 51 | | |
| |||
80 | 93 | | |
81 | 94 | | |
82 | 95 | | |
| 96 | + | |
| 97 | + | |
83 | 98 | | |
84 | 99 | | |
85 | 100 | | |
| |||
111 | 126 | | |
112 | 127 | | |
113 | 128 | | |
| 129 | + | |
114 | 130 | | |
115 | 131 | | |
116 | 132 | | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
117 | 145 | | |
118 | 146 | | |
119 | 147 | | |
| |||
142 | 170 | | |
143 | 171 | | |
144 | 172 | | |
| 173 | + | |
| 174 | + | |
145 | 175 | | |
146 | 176 | | |
147 | 177 | | |
| |||
0 commit comments