Skip to content

Correct End-to-End Behaviour of the Dynamic Agent/Process Configuration Form ​

Context ​

The Form Duality system in packages/core/swiss_ai_hub/core/form/ lets a single Pydantic class double as a form schema and a typed data model. It powers the dynamic agent and process configuration forms in packages/web (useFormKitTransform.ts, Agent/CreateModal.vue, DynamicConfiguration.vue). Two earlier ADRs established this area: 2026_01_07_enable_dynamic_agent_configuration_ui.md (render backend-defined schemas as forms) and 2026_05_07_nullable_form_fields_over_enabled_toggle.md (treat T | None as the source of truth for an optional sub-form and synthesize a __<field>__enabled toggle in the frontend).

An end-to-end audit of the configuration form (five parallel reviews over backend generation, frontend transform, create/edit data-flow, round-trip integrity, and custom inputs) surfaced a cluster of correctness defects that all shared one root: the form's create path and edit path were two separate implementations of "schema → hydrated model → submitted payload", and several invariants held in one but not the other. Concretely, against a real RAGAgent:

  • A nullable field with a non-null default (system_prompt, context_prompt, and org_memory, which defaults to a populated OrgMemoryReadConfig()) loaded enabled on edit but came up disabled on a fresh create, so its default was silently dropped at submit. 2026_05_07's seedNullableToggles() only handled the saved-data case (field !== null); the fresh-create case was never specified.
  • The edit submit never stripped the repeater __validate__<name>__<index> mirror keys (only create did), so the two endpoints validating the same config sanitized it asymmetrically.
  • Repeater rows were keyed by array index (both as the Vue :key and inside the per-item FormKit group id), so deleting a non-last row reindexed survivors and rebound FormKit groups to the wrong row's data ([5, 5, 22] → delete middle → [5, 5]).
  • A useChangeCase (VueUse) composable used in selector option resolvers returns a ComputedRef; display_name || useChangeCase(name, 'capitalCase') leaked a Ref into PrimeVue/FormKit labels and, because it ran inside render computeds, spawned throwaway reactive effects every render — the instability behind intermittently blank/broken forms.
  • PrimeVue InputNumber is integer-only unless fraction digits are configured, so fractional fields (LLM temperature, step 0.1) rejected the decimal point.
  • buildFormKitSchema wrapped the whole element list in one try/catch returning [], so a single throwing element wiped an entire section (most visibly the Basic Info step, leaving an unsubmittable form).

These are not abstract: each was reproduced live, and each is the kind of regression that the absence of a frontend test harness (make test is a no-op in packages/web) lets through silently.

Decision Drivers ​

  • One behaviour, not two
    The create and edit forms must hydrate and serialize identically. Divergent pipelines are the structural cause of "works on edit, broken on create" (and vice-versa); every such pair of code paths is a latent inconsistency.
  • The schema must carry enough to render a fresh form correctly
    2026_05_07 made T | None the toggle's source of truth, but the initial toggle state on a brand-new form depends on whether the field's data default is null — information the serialized schema did not preserve. The duality pattern requires as_form() to always emit a fully-populated Group (so children can render when re-enabled), and a Group extends FormkitElement, which has no value field. So a None-default nullable group (reranking_config = None) and an object-default one (org_memory = OrgMemoryReadConfig()) serialize identically. The frontend cannot infer the difference; the backend — the only place the Pydantic default is visible at schema-generation time — must transmit it.
  • Defaults are a contract, not decoration
    A field that declares a default must present that default in the form and persist it unless the user changes it. Dropping system_prompt's default because a toggle defaulted off is a silent contract violation.
  • Stable identity for list rows
    A repeater row's UI identity must track the logical row, not its current array index, or editing one row after deleting another corrupts data.
  • Pure functions in render paths
    Option/label resolvers run inside Vue render computeds; they must be pure (no reactive-effect allocation, no Ref leakage into props) to keep rendering stable.
  • Graceful degradation over all-or-nothing
    One malformed schema element should drop itself, not the whole form. A config form that can't be submitted because of one bad field is worse than one missing field.
  • Symmetric server-side sanitisation
    Create and update must normalize submitted config through the same path so neither endpoint is one frontend bug away from persisting FormKit artifacts.

Decision ​

Make the configuration form correct end-to-end by unifying its pipelines and closing the create-path gap left by 2026_05_07, plus targeted fixes for the independent defects found in the same audit.

1. default_enabled signal (backend → frontend). FormkitElement gains default_enabled: bool | None (alias defaultEnabled). Form.to_formkit_form() sets it on nullable leaves and groups from _default_is_non_null(field_info) (default is not None and not PydanticUndefined). The frontend's seedNullableToggles() now falls back to default_enabled when a field is absent from the source data (a fresh form), while still using the value's null-ness when it is present (edit/clone). This is the create-path completion of 2026_05_07: that ADR specified the saved-data case only. BaseModel defaults (e.g. a LocaleString prompt) are model_dump()-ed into the primitive value so they no longer trip the Pydantic serializer.

2. Unify create and edit behind two shared functions in useFormKitTransform.ts:

  • hydrateFormData = seedFormDefaults ∘ seedNullableToggles — raw saved/template/empty data → hydrated model.
  • serializeFormData = cleanFormData ∘ normalizeFormLocaleStrings ∘ coerceNullableToggles — model → submission payload.

useCreateInstanceForm drops its bespoke initializeGroupData / stripNullsForGroups / cleanFormData; DynamicConfiguration and both create modals call the shared helpers. This removes the create-path default discard and the edit-path __validate__ leak in one move.

3. Stable repeater row keys. Repeater.vue assigns each row a crypto.randomUUID() on add/load and keys both the v-for and the per-item FormKit group (__validate__<name>__<uuid>) by it. add/remove keep the key array aligned; a watch reconciles external model replacement. The mirror keys keep the __validate__ prefix and are still stripped at submit by serializeFormData.

4. Auto-derive InputNumber fraction digits. InputNumber derives max_fraction_digits from the field's own precision (step/min/max/value) when unset, so fractional fields accept decimals while integer fields stay integer-only.

5. Pure capitalCase in resolvers. Replace useChangeCase(x, 'capitalCase') with the synchronous capitalCase(x) from change-case across all selector option resolvers and related pages, eliminating the Ref-in- label class and per-render reactive-effect churn.

6. Per-element error isolation. buildFormKitSchema and the group/repeater extractors wrap each element individually, so a malformed element is skipped and logged while siblings render.

7. Symmetric server-side normalisation. Agent and process create endpoints route through InstanceConfigHelper.normalize_form_configuration (which strips FormKit _-prefixed keys), matching update.

Deferred, latent/structural items were filed rather than bundled: transform hardening (#1475: combineConditions string surgery, isLocaleStringObject heuristic, schema churn, merge-arrays, nested repeaters), backend persistence canonicalisation (#1476: storing model_dump() and de-duplicating identity fields), and a Vitest harness for the transform layer (#1477).

Consequences ​

Positive ​

  • Create and edit forms hydrate and serialize through one code path, so behaviour is provably consistent and the "works in one, broken in the other" class is closed.
  • Fresh forms present nullable fields enabled exactly when their data default is non-null; declared defaults are no longer silently discarded.
  • A disabled nullable group still round-trips as null (verified: DB keeps reranking_config/org_memory null on save) with no __validate__/slots leakage from either endpoint.
  • Deleting a repeater row no longer corrupts the surviving rows.
  • Fractional numeric inputs accept decimals; one bad element can't blank the form.

Trade-offs ​

  • default_enabled adds a field to FormkitElement that is meaningful only for nullable elements. It duplicates, on the wire, information the backend already holds — a deliberate cost, because the serialized group schema cannot otherwise convey it (see the second Decision Driver). An alternative — inferring from element.value for leaves and leaving groups unhandled — was rejected as two code paths for one concept.
  • The repeater still relies on the __validate__<field>__<key> mirror group for per-item FormKit validation; the keys are now stable and stripped at submit, but the mirror itself remains (full removal is follow-up).
  • The frontend transform layer carries shared hydrateFormData/serializeFormData conventions and synthetic __<field>__enabled keys, visible to anyone reading raw formData mid-edit. With no Vitest harness yet (#1477) these invariants are guarded only by the packages/core form tests and manual verification.
  • This branch corrected behaviour but did not address the structural persistence smells (dual-stored identity fields, raw-vs-model_dump() storage) deferred to #1476.

Built with ❤️ in Switzerland 🇨🇭