Skip to content

feat(server,sdk): Control Templates#158

Open
lan17 wants to merge 27 commits intomainfrom
lev/controltemplates
Open

feat(server,sdk): Control Templates#158
lan17 wants to merge 27 commits intomainfrom
lev/controltemplates

Conversation

@lan17
Copy link
Copy Markdown
Contributor

@lan17 lan17 commented Apr 1, 2026

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

@lan17 lan17 changed the title feat: add control template support feat(server): Control Temlates Apr 1, 2026
@lan17 lan17 changed the title feat(server): Control Temlates feat: add control template support Apr 1, 2026
@lan17 lan17 changed the title feat: add control template support feat(server,sdk): add control template support Apr 1, 2026
@lan17 lan17 marked this pull request as ready for review April 1, 2026 07:08
@lan17 lan17 changed the title feat(server,sdk): add control template support feat(server,sdk): Control Templates Apr 1, 2026
lan17 added 9 commits April 1, 2026 00:19
Template controls can now be created without providing parameter values.
The server validates the template structure but skips rendering, storing
the template metadata with enabled=false. Unrendered controls are visible
in listings, attachable to agents, but excluded from evaluation.

- Add UnrenderedTemplateControl model for GET response union
- Create endpoint branches: complete values render, incomplete store unrendered
- Update endpoint supports unrendered→rendered transition when values provided
- GET endpoints discriminate via condition key presence in stored JSONB
- PATCH rejects enabling unrendered templates with 422
- ControlSummary gains template_rendered field
- Runtime evaluation query skips unrendered templates
- Agent policy validation skips unrendered templates
- 10 new server tests covering the full unrendered lifecycle
- Prevent rendered→unrendered downgrade: updating a rendered template
  control with incomplete values now forces a full render attempt,
  returning a clear error about missing parameters instead of silently
  stripping rendered fields
- Deepen unrendered structural validation: validate_template_structure
  now walks definition_template to check $param bindings, reject
  undefined parameter references, detect unused parameters, and reject
  hardcoded agent-scoped evaluator names
- Fix PATCH enabled=false on unrendered templates: detect unrendered
  state before attempting ControlDefinition.model_validate, treating
  disable as a no-op instead of raising CORRUPTED_DATA
- Add 4 behavioral tests: rendered rejects incomplete update, PATCH
  disable no-op, unrendered rejects undefined $param / unused param /
  agent-scoped evaluator
- Reject optional params without defaults in unrendered structural
  validation (catches templates that can never render at creation time)
- Fix PATCH rename-only on unrendered templates: detect unrendered state
  before ControlDefinition.model_validate to avoid false CORRUPTED_DATA
- Export UnrenderedTemplateControl from Python SDK
- Strengthen rename test to verify enabled=false in response
- Add test for optional-param-without-default rejection on unrendered create
- Reject unknown keys and wrong-typed values in partial template_values
  on unrendered create (fail fast instead of persisting garbage)
- Deduplicate structural validation: render_template_control_input now
  calls validate_template_structure instead of inlining the same checks
- Fix description fallback in list endpoint: unrendered templates show
  template.description when top-level description is absent
- Fix _reject_hardcoded_agent_scoped_evaluators to report actual
  condition path instead of hardcoded "condition.evaluator.name"
- Fix PATCH error message indentation
- Move UnrenderedTemplateControl import to module level in controls service
- Add tests: unknown value key rejection, wrong-type value rejection,
  description fallback in list
- Wrap UnrenderedTemplateControl.model_validate in _parse_stored_control_data
  with proper error handling (returns 422 CORRUPTED_DATA instead of 500)
- Wrap unrendered parse in list_controls_for_agent with try/except to skip
  corrupted rows instead of crashing the entire listing
- Remove redundant unused-parameter check in render_template_control_input
  (already caught by validate_template_structure called at the top)
…elper

- Skip unrendered template controls in check_evaluation_with_local so
  they don't trigger the server-call fallback (prevents hot-path latency
  regression for agents with attached unrendered templates)
- Accept UnrenderedTemplateControl in to_template_control_input so
  callers can round-trip unrendered template data from GET endpoints
- Add test: unrendered template does not trigger server fallback
- Add test: to_template_control_input accepts unrendered template data
- Make /controls/validate mirror create: incomplete template values
  validate structure only (returns 200) instead of forcing a full render
  that rejects missing params. Use the render preview endpoint to check
  renderability.
- Exclude unrendered templates from list filters that reference
  rendered-only fields (execution, step_type, stage, tag). Unrendered
  templates still appear in unfiltered listings and template_backed
  filter.
- Update validate test to expect 200 for incomplete values
- Add test: validate rejects structurally invalid unrendered templates
- Add test: unrendered templates excluded from rendered-field filters
  but included in unfiltered listings
lan17 added 3 commits April 1, 2026 17:41
List elements now inherit their parent's depth instead of incrementing
it. A flat array of strings at depth 11 no longer pushes each element
to depth 12+, which was incorrectly rejecting real-world templates with
nested boolean condition trees containing list-valued parameters.
# Conflicts:
#	models/src/agent_control_models/__init__.py
#	models/src/agent_control_models/controls.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant