deepclause-sdk 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +72 -3
  2. package/dist/agent.d.ts +17 -0
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +148 -42
  5. package/dist/agent.js.map +1 -1
  6. package/dist/cli/config.d.ts +12 -12
  7. package/dist/cli/repl.d.ts +13 -1
  8. package/dist/cli/repl.d.ts.map +1 -1
  9. package/dist/cli/repl.js +146 -5
  10. package/dist/cli/repl.js.map +1 -1
  11. package/dist/forms/index.d.ts +3 -0
  12. package/dist/forms/index.d.ts.map +1 -0
  13. package/dist/forms/index.js +3 -0
  14. package/dist/forms/index.js.map +1 -0
  15. package/dist/forms/registry.d.ts +119 -0
  16. package/dist/forms/registry.d.ts.map +1 -0
  17. package/dist/forms/registry.js +256 -0
  18. package/dist/forms/registry.js.map +1 -0
  19. package/dist/forms/spec.d.ts +193 -0
  20. package/dist/forms/spec.d.ts.map +1 -0
  21. package/dist/forms/spec.js +295 -0
  22. package/dist/forms/spec.js.map +1 -0
  23. package/dist/index.d.ts +7 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +7 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/prolog/loader.d.ts +18 -0
  28. package/dist/prolog/loader.d.ts.map +1 -1
  29. package/dist/prolog/loader.js +26 -1
  30. package/dist/prolog/loader.js.map +1 -1
  31. package/dist/prolog-src/deepclause_mi.pl +650 -28
  32. package/dist/repl/agent.d.ts +4 -0
  33. package/dist/repl/agent.d.ts.map +1 -1
  34. package/dist/repl/agent.js +2 -0
  35. package/dist/repl/agent.js.map +1 -1
  36. package/dist/repl/form-widget.d.ts +56 -0
  37. package/dist/repl/form-widget.d.ts.map +1 -0
  38. package/dist/repl/form-widget.js +289 -0
  39. package/dist/repl/form-widget.js.map +1 -0
  40. package/dist/repl/harness-tools.d.ts +3 -1
  41. package/dist/repl/harness-tools.d.ts.map +1 -1
  42. package/dist/repl/harness-tools.js +13 -56
  43. package/dist/repl/harness-tools.js.map +1 -1
  44. package/dist/repl/history.d.ts +2 -1
  45. package/dist/repl/history.d.ts.map +1 -1
  46. package/dist/repl/history.js +34 -8
  47. package/dist/repl/history.js.map +1 -1
  48. package/dist/repl/index.d.ts +14 -2
  49. package/dist/repl/index.d.ts.map +1 -1
  50. package/dist/repl/index.js +6 -0
  51. package/dist/repl/index.js.map +1 -1
  52. package/dist/repl/process-backend.d.ts +2 -2
  53. package/dist/repl/process-backend.d.ts.map +1 -1
  54. package/dist/repl/process-executor.d.ts +11 -2
  55. package/dist/repl/process-executor.d.ts.map +1 -1
  56. package/dist/repl/process-executor.js +67 -18
  57. package/dist/repl/process-executor.js.map +1 -1
  58. package/dist/repl/process-worker.d.ts +35 -4
  59. package/dist/repl/process-worker.d.ts.map +1 -1
  60. package/dist/repl/process-worker.js +78 -38
  61. package/dist/repl/process-worker.js.map +1 -1
  62. package/dist/repl/processes.d.ts +4 -0
  63. package/dist/repl/processes.d.ts.map +1 -1
  64. package/dist/repl/processes.js +20 -0
  65. package/dist/repl/processes.js.map +1 -1
  66. package/dist/repl/render.d.ts +17 -0
  67. package/dist/repl/render.d.ts.map +1 -1
  68. package/dist/repl/render.js +50 -0
  69. package/dist/repl/render.js.map +1 -1
  70. package/dist/repl/session.d.ts +112 -7
  71. package/dist/repl/session.d.ts.map +1 -1
  72. package/dist/repl/session.js +241 -15
  73. package/dist/repl/session.js.map +1 -1
  74. package/dist/repl/tui/form-view.d.ts +17 -0
  75. package/dist/repl/tui/form-view.d.ts.map +1 -0
  76. package/dist/repl/tui/form-view.js +25 -0
  77. package/dist/repl/tui/form-view.js.map +1 -0
  78. package/dist/repl/tui/renderer.d.ts +4 -0
  79. package/dist/repl/tui/renderer.d.ts.map +1 -1
  80. package/dist/repl/tui/renderer.js +12 -0
  81. package/dist/repl/tui/renderer.js.map +1 -1
  82. package/dist/repl/worker-host.d.ts +16 -2
  83. package/dist/repl/worker-host.d.ts.map +1 -1
  84. package/dist/repl/worker-host.js +38 -6
  85. package/dist/repl/worker-host.js.map +1 -1
  86. package/dist/repl/worker-protocol.d.ts +14 -0
  87. package/dist/repl/worker-protocol.d.ts.map +1 -1
  88. package/dist/repl/worker-protocol.js +8 -1
  89. package/dist/repl/worker-protocol.js.map +1 -1
  90. package/dist/runner.d.ts +63 -1
  91. package/dist/runner.d.ts.map +1 -1
  92. package/dist/runner.js +361 -40
  93. package/dist/runner.js.map +1 -1
  94. package/dist/sdk.d.ts.map +1 -1
  95. package/dist/sdk.js +12 -0
  96. package/dist/sdk.js.map +1 -1
  97. package/dist/system/assets/docs/AGENT_PROMPT.md +441 -18
  98. package/dist/system/assets/docs/CHAT_PROMPT.md +28 -0
  99. package/dist/system/assets/docs/DML_AUTHORING.md +1 -1
  100. package/dist/system/assets/docs/DML_FORMS.md +740 -0
  101. package/dist/system/assets/docs/DML_REFERENCE.md +234 -4
  102. package/dist/system/assets/docs/HARNESS_EXAMPLES.md +94 -0
  103. package/dist/system/assets/index.d.ts +2 -2
  104. package/dist/system/assets/index.d.ts.map +1 -1
  105. package/dist/system/assets/index.js +4 -0
  106. package/dist/system/assets/index.js.map +1 -1
  107. package/dist/system/environment.d.ts +6 -0
  108. package/dist/system/environment.d.ts.map +1 -1
  109. package/dist/system/environment.js +5 -4
  110. package/dist/system/environment.js.map +1 -1
  111. package/dist/types.d.ts +35 -1
  112. package/dist/types.d.ts.map +1 -1
  113. package/package.json +9 -7
  114. package/src/prolog-src/deepclause_mi.pl +650 -28
@@ -0,0 +1,740 @@
1
+ # Auto-generated forms
2
+
3
+ A harness can ask the human a **structured question** — a form — and act on the
4
+ answer, over several rounds, with the UI living outside the model. The agent
5
+ declares a form, waits for a change, calls tools with the updated values, updates
6
+ the form, and waits again.
7
+
8
+ > **Status: implemented.** Forms are always available — there is no directive and
9
+ > nothing to enable (§4 records why the original opt-in gate was removed). This
10
+ > document is both the reference and the design record.
11
+ >
12
+ > **Built:** the primitives
13
+ > `form/2`, `form_update/2`, `form_errors/2`, `await_form/2,3`, `close_form/1`;
14
+ > the model tools `render_form`, `update_form`, `set_form_issues`,
15
+ > `wait_for_form_change`, `validate_form`, `advisories`, `close_form`;
16
+ > host-enforced hard validation (the spec's own constraints *and* the harness's
17
+ > `form_validate/2`) — a refused submit is re-shown by the host and the wait
18
+ > continues; soft `form_advisories/2`;
19
+ > the `form` event and `submitForm`; a TUI panel and a plain-TTY widget;
20
+ > `object`/`array` groups, `enum`, and the `row`/`column`/`grid`/`box` layout.
21
+ >
22
+ > **Not built:** in-place field navigation in the TUI panel (the plain-TTY widget
23
+ > has it); forms owned by a subagent (an L2 worker cannot forward a form to the
24
+ > foreground UI); repeaters (`array` of `object` renders but has no add/remove UI);
25
+ > `tabs`; cross-turn surfaces.
26
+ >
27
+ > **Try it:** `reference("examples")` has a complete harness, and
28
+ > `sdk-examples/forms.dml` runs with no model (`deepclause repl -l
29
+ > sdk-examples/forms.dml` then `/goal agent_main`).
30
+
31
+ Forms generalize two things that came first: `media` events (a program declares
32
+ host-rendered content — [`DML_MEDIA.md`](DML_MEDIA.md)) and `input/2` (a blocking
33
+ host request that suspends an engine and resumes it).
34
+
35
+ ---
36
+
37
+ ## 1. Why
38
+
39
+ Most "generative UI" streams markup and re-renders. That is fine for a read-out,
40
+ but a form is a **state machine with a decision at the end**: the human edits,
41
+ presses an action, and the agent must do something with the result. Modelling it
42
+ as data the host owns — rather than text the model streams — gives four things:
43
+
44
+ - **A stable contract.** The agent references fields by name; the presentation
45
+ can change under it without breaking its tools.
46
+ - **A place for validation.** Shape is checked before the agent ever sees values.
47
+ - **A round trip.** The agent acts between updates, not just re-renders.
48
+ - **Renderer independence.** The same form works in a TUI, a web embed, and
49
+ `--json`, because it is data.
50
+
51
+ ## 2. The model
52
+
53
+ A form is **host-owned data**, not model state and not UI state:
54
+
55
+ ```
56
+ Form = { id, revision, spec, values, state }
57
+ ```
58
+
59
+ - The **agent** (via tools) or a **DML program** (via primitives) declares and
60
+ updates it, then waits for a change.
61
+ - The **UI** renders it and pushes changes back.
62
+ - A **change** is `{ formId, revision, action, values, changed }`.
63
+
64
+ In DML the `action` is an **atom**, so a harness tests it with `==`:
65
+
66
+ ```prolog
67
+ await_form(F, Change),
68
+ ( Change.action == submit -> ...
69
+ ; Change.action == cancel -> ...
70
+ ).
71
+ ```
72
+
73
+ One of `submit`, `cancel`, `closed`, `cancelled`, `timeout`, `unavailable`, or an
74
+ action the spec declared. (Under `:>`, a `task/N` output wrapped in a dict, the
75
+ change reaches the model as JSON, where the same value is the string `"submit"` —
76
+ the atom is a DML-side convenience, applied by `form_change_atom_action/2`.)
77
+
78
+ **A refused submit never appears here.** The host validates on submit (the spec's
79
+ constraints and the harness's `form_validate/2`); when they reject it, the host
80
+ attaches the errors to the form and the wait continues — see §9. So the loop above
81
+ has no error branch, and a caller only ever sees an action the rules accepted.
82
+
83
+ The loop:
84
+
85
+ ```
86
+ render_form(spec) → form event; returns { formId, revision }
87
+ wait_for_form_change(formId) → blocks
88
+ ▲ │ user edits, presses "Submit"
89
+ │ ▼
90
+ ← { action:'submit', values, revision }
91
+ → refund(values) (any tool: the submit handler)
92
+ → update_form(formId, patch) re-render results into the form
93
+ → wait_for_form_change(formId) wait again … or finish
94
+ ```
95
+
96
+ ## 3. Where it lives: primitives first, tools as sugar
97
+
98
+ The mechanism is a host-side **form registry**. It is reached two ways, and the
99
+ primitive is the core — so it works with no model and no `task/N`. None of it is
100
+ active by default; §4 is how a harness opts in:
101
+
102
+ ```
103
+ ┌────────────────────────────────────────────┐
104
+ model-facing │ tool(render_form/…) · wait_for_form_change │ tool/2 wrappers
105
+ ├────────────────────────────────────────────┤
106
+ author-facing │ form/2 · form_update/2 · await_form/2,3 │ mi_call primitives
107
+ ├────────────────────────────────────────────┤
108
+ host │ FormRegistry + `form` events + submitForm │ one mechanism
109
+ └────────────────────────────────────────────┘
110
+ ```
111
+
112
+ The model-facing tools are ordinary DML wrappers:
113
+
114
+ ```prolog
115
+ tool(render_form(Spec, FormId), "Open (or replace) a form.") :-
116
+ form(Spec, FormId).
117
+
118
+ tool(wait_for_form_change(FormId, Change), "Wait for the user to act on a form.") :-
119
+ await_form(FormId, Change).
120
+ ```
121
+
122
+ This makes the agent flow a special case of the primitive, and lets a harness
123
+ drive a form with **no model at all**:
124
+
125
+ ```prolog
126
+ agent_main :-
127
+ form(Spec, F),
128
+ await_form(F, Change),
129
+ ( Change.action == submit
130
+ -> get_dict(values, Change, V),
131
+ refund(V),
132
+ form_update(F, _{ notes: "Refund issued" }),
133
+ await_form(F, _),
134
+ answer("refunded")
135
+ ; close_form(F),
136
+ answer("cancelled")
137
+ ).
138
+ ```
139
+
140
+ **In a conversation, the wait is implicit.** A `chat/1` turn does not end while a
141
+ form it opened is still unanswered (`FormRegistry.awaitingForms()`): if the model
142
+ renders a form and then replies without calling `wait_for_form_change`, the turn
143
+ waits for the person and hands back the submission as the next input. So a chat
144
+ model only has to call `render_form`; `wait_for_form_change` is for another round
145
+ on a form kept open.
146
+
147
+ `form/2` is a *display* primitive (like `output/1`); `await_form/2` is the
148
+ request. A program may render a form and never await it.
149
+
150
+ ## 4. Activation: none — forms are always available
151
+
152
+ There is nothing to enable. A harness calls `form/2` and it works, in the REPL, in
153
+ `execute`, and in a `deepclause run` file.
154
+
155
+ This section is the record of why. Forms were first built behind an opt-in
156
+ capability directive (`:- capability(forms).`). The gate was **removed**: it cost
157
+ more than it bought, because the feature was invisible and the probe for it was
158
+ actively misleading.
159
+
160
+ - **A gated primitive is a handler, not a clause.** `current_predicate(form/2)`
161
+ reports `no` in a session where forms work — exactly as it does for `exec/2`.
162
+ An agent that checks availability the obvious way concludes the opposite of
163
+ the truth, and nothing in the language tells it otherwise.
164
+ - **The directive was file-scoped.** It was processed at load, so it applied to a
165
+ harness loaded from a file, not to a module assembled through the edit tools
166
+ (`begin_turn`/`define`). A form could not be used until the author noticed and
167
+ moved to a file — a second, unrelated concept to learn.
168
+ - **The gate guarded nothing dangerous.** A form is inert data rendered by the
169
+ host; the operations a model performs *after* reading it are gated by the
170
+ ordinary tool policy. Opting in bought no isolation.
171
+
172
+ So the `tool/N` wrappers are now **installed into every session** by
173
+ `install_builtin_tools/1` (`deepclause_mi.pl`), through the same `process_clause/2`
174
+ path an authored tool takes. They land in `session_user_tools/2` /
175
+ `session_user_tool_schema/3`, so `task/N` offers them, `with_tools/2` scopes them,
176
+ `listing(tools)` shows them, and an export carries their source (a standalone run
177
+ of a harness that uses them keeps working). The install is idempotent —
178
+ `parse_clauses/2` runs on every load, and a duplicated schema would show the model
179
+ the same tool twice.
180
+
181
+ The primitives themselves (`form/2`, `await_form/2`, …) are `mi_call` handlers and
182
+ were always defined; only the gate in front of them was removed.
183
+
184
+ A file written against the old directive still loads: `capability/1` is a benign
185
+ directive, ignored rather than reported as an unhandled one.
186
+
187
+ ---
188
+
189
+ ## 5. The representation
190
+
191
+ Four rules:
192
+
193
+ 1. **Data, not code.** A spec is inert JSON — no expressions, no handlers, no HTML.
194
+ 2. **One tree, with the data/presentation split inside each node** (rather than a
195
+ separate schema + UI schema, which duplicates and is tedious for a model).
196
+ 3. **`type` ≠ `widget`.** Validation reads only the data half; `widget` is a hint.
197
+ 4. **JSON-native.** The same object is the TypeScript type, the wire format, and
198
+ (via a JSON Schema) what the model is constrained to produce.
199
+
200
+ ### 5.1 Fields
201
+
202
+ ```ts
203
+ interface FieldBase {
204
+ name: string; // unique within its parent — the contract
205
+ type: FieldType; // data type (drives validation)
206
+ required?: boolean;
207
+ default?: Json;
208
+ title?: string; // human label
209
+ description?: string; // help text
210
+ widget?: Widget; // presentation hint only
211
+ }
212
+ ```
213
+
214
+ Data types, grouped by **type** rather than by widget:
215
+
216
+ ```ts
217
+ { type: 'string', minLength?, maxLength?, pattern?, format?: 'date'|'time'|'datetime'|'email'|'uri'|'media' }
218
+ { type: 'number' | 'integer', minimum?, maximum?, multipleOf? }
219
+ { type: 'boolean' }
220
+ { type: 'string', enum: string[], options?: { value: string; title?: string; description?: string }[] }
221
+ { type: 'object', properties: Field[] } // a group
222
+ { type: 'array', items: Field, minItems?, maxItems?, uniqueItems? } // a repeater
223
+ { type: 'null', widget: 'hidden' }
224
+ ```
225
+
226
+ `format: 'media'` aligns with the media model: a file field's value is a
227
+ workspace `media{path, mime}` reference, not bytes.
228
+
229
+ The `widget` vocabulary is renderer-facing and advisory: `text`, `textarea`,
230
+ `number`, `slider`, `select`, `radio`, `multiselect`, `checkbox`, `switch`,
231
+ `date`, `time`, `datetime`, `hidden`, `tabs`, `fieldset`. A renderer that does not
232
+ know one falls back to the default for its type.
233
+
234
+ ### 5.2 Paths
235
+
236
+ Fields form a tree for structure and values, but the **canonical address is the
237
+ path**:
238
+
239
+ ```
240
+ customer.name
241
+ lines[0].sku
242
+ ```
243
+
244
+ Paths are what validation errors, patches, and `changed` refer to. `name` is the
245
+ contract; `title`/`widget`/order/layout are presentation and may change freely.
246
+
247
+ ### 5.3 Layout
248
+
249
+ Layout is a **separate tree over field paths**:
250
+
251
+ > Moving a field between two columns must never change its path, its value, or
252
+ > its validation.
253
+
254
+ Nesting fields inside columns would silently rename `customer.name` and break the
255
+ agent's tools. So `fields` describes the data; `layout` describes the
256
+ arrangement.
257
+
258
+ ```ts
259
+ type LayoutNode =
260
+ | { kind: 'row'; children: LayoutNode[]; gap?: number; align?: 'start'|'center'|'end'|'stretch'; wrap?: boolean }
261
+ | { kind: 'column'; children: LayoutNode[]; gap?: number }
262
+ | { kind: 'grid'; columns: number; gap?: number; children: LayoutNode[] }
263
+ | { kind: 'box'; title?: string; description?: string; variant?: 'plain'|'card'|'fieldset'; children: LayoutNode[] }
264
+ | { kind: 'tabs'; tabs: { title: string; children: LayoutNode[] }[] }
265
+ | { kind: 'field'; field: string; span?: number; widget?: string; label?: string; help?: string }
266
+ | { kind: 'text'; text: string; variant?: 'body'|'caption'|'heading' }
267
+ | { kind: 'divider' }
268
+ | { kind: 'spacer'; size?: number };
269
+
270
+ interface FormSpec {
271
+ version: 1;
272
+ id?: string;
273
+ title?: string;
274
+ description?: string;
275
+ fields: Field[]; // the data tree
276
+ layout?: LayoutNode[]; // the presentation tree, over field paths
277
+ initial?: Record<string, Json>;
278
+ actions?: Action[];
279
+ ui?: { submitOnEnter?: boolean; dense?: boolean };
280
+ meta?: Record<string, Json>; // namespaced, ignored by the protocol
281
+ }
282
+ ```
283
+
284
+ Choices worth stating:
285
+
286
+ - **`grid` + `span`, not columns as nesting.** `span` is in grid columns, so
287
+ `columns: 12, span: 6` is CSS-familiar and `columns: 2, span: 2` means full width.
288
+ - **`box` subsumes "sections."** A titled, bordered container *is* a section.
289
+ - **`text`/`divider`/`spacer`** cover labels and breathing room that aren't fields.
290
+ - **No conditions.** The agent re-renders instead; a condition language drags in a
291
+ client/server evaluation mismatch.
292
+
293
+ **Precedence.** Two rules keep partial layouts safe and avoid a second source of
294
+ truth:
295
+
296
+ 1. Fields **not referenced** by the layout render afterwards, in `fields` order,
297
+ in a default single-column flow. Nothing disappears.
298
+ 2. A `field` leaf may **override presentation only** — `widget`, `label`, `help`,
299
+ `span`. The field's `type`, constraints, and `name` always win. Renderers
300
+ dedupe by path, first occurrence winning.
301
+
302
+ **Responsive / stacking.** Character grids have no media queries, so the rule is
303
+ structural and each renderer applies it:
304
+
305
+ - A `row` stacks when `availableWidth < sum(child.minWidth)`, or when `wrap: true`.
306
+ - A `grid` reduces its column count by the same rule; `span` clamps to it.
307
+ - Each leaf has an implicit `minWidth` (a text input ~16, a select ~12) that a
308
+ renderer may override.
309
+
310
+ The spec stays width-agnostic; only the adapters know about cells and pixels.
311
+
312
+ ### 5.4 Values
313
+
314
+ Values **mirror the tree** and are JSON:
315
+
316
+ ```json
317
+ { "customer": { "name": "Ana", "vip": true },
318
+ "lines": [ { "sku": "X-1", "qty": 2 } ],
319
+ "reason": "duplicate_charge" }
320
+ ```
321
+
322
+ - **Defaults are resolved, not stored.** `applyDefaults(spec, values)` fills
323
+ missing paths deepest-first, so the UI and the agent always get a complete value
324
+ set — "blank" and "never rendered" never become the same question.
325
+ - **Coercion is defensive.** `"2"` for an integer is coerced where unambiguous and
326
+ recorded as an error where not; a string is never silently stored in a number.
327
+
328
+ ### 5.5 Actions and changes
329
+
330
+ ```ts
331
+ interface Action {
332
+ name: string; // 'submit' | 'cancel' | custom
333
+ title: string;
334
+ kind?: 'primary' | 'default' | 'danger' | 'link';
335
+ confirm?: string;
336
+ validates?: 'all' | string[]; // paths checked before the action is allowed
337
+ }
338
+
339
+ interface FormChange {
340
+ formId: string;
341
+ revision: number; // monotonic; the race-free cursor
342
+ action: string; // submit | cancel | custom | timeout | unavailable | cancelled
343
+ values: Record<string, Json>; // full values, defaults resolved
344
+ changed?: string[]; // paths that differ from the previous revision
345
+ }
346
+ ```
347
+
348
+ Full values, not a delta: forms are small, it is idempotent, and the agent never
349
+ replays history. `changed` lets the agent react to "the user just edited the
350
+ order lines."
351
+
352
+ ### 5.6 Patching
353
+
354
+ `update_form(formId, patch)` takes a **JSON Merge Patch** (RFC 7386): simpler than
355
+ JSON Patch and sufficient for "fill a computed field / add options." Arrays
356
+ replace wholesale. Every apply bumps `revision`.
357
+
358
+ ## 6. Validation
359
+
360
+ Two functions, deliberately separated, both returning **data** so the same errors
361
+ drive UI messages and the tool result the agent sees:
362
+
363
+ - `validateSpec(spec): SpecError[]` — static: unknown widget, `enum` on a
364
+ non-string, `minimum > maximum`, duplicate path, layout referencing an unknown
365
+ field, `span > columns`, depth/size limits.
366
+ - `validateValues(spec, values): FieldError[]` — dynamic: `{ path, code, message }`.
367
+
368
+ Cross-field rules (`end > start`) are **not** in the spec; that is logic, and the
369
+ agent (or a tool) owns it — the same line DML already draws elsewhere.
370
+
371
+ **Who acts on a rejection.** The host does, and only the host. On a submit it runs
372
+ both checks; when they produce errors it attaches them to the form (so the person
373
+ filling it in sees what is wrong, next to the value they typed) and keeps the
374
+ wait open. `await_form/2` loops internally: it returns the next *accepted* change,
375
+ or a cancel. So a harness reads
376
+
377
+ ```prolog
378
+ await_form(F, Change), % Change.action is submit or cancel — never a refusal
379
+ ```
380
+
381
+ with no error branch, and a model driving the loop with
382
+ `wait_for_form_change/1` likewise never sees a refusal. A caller that wants the
383
+ raw errors before submitting can ask: `form_validate/2` (the `validate_form`
384
+ tool) runs the same checks without touching the form.
385
+
386
+ ## 7. Protocol
387
+
388
+ **Host → consumer** is the existing event stream; one new `DMLEvent`:
389
+
390
+ ```ts
391
+ { type: 'form'; formId: string; revision: number;
392
+ op: 'open' | 'update' | 'close';
393
+ form?: FormSpec; patch?: JsonMergePatch; values?: Record<string, Json>;
394
+ issues?: FormIssue[]; // errors to show against fields
395
+ state?: 'open' | 'submitted' | 'cancelled';
396
+ source?: { name?: string; pid?: string } // attribution when a subagent owns it
397
+ }
398
+ ```
399
+
400
+ `spec` travels with an `update` too: a renderer that only receives the update
401
+ still needs the fields to draw, and the `issues` to show against them.
402
+
403
+ **Consumer → host** mirrors `provideInput`:
404
+
405
+ ```ts
406
+ sdk.submitForm(formId, { action, values, revision }); // SDK
407
+ session.submitForm(formId, { action, values, revision }); // REPL / DMLSession
408
+ ```
409
+
410
+ The generator is host→UI; `submitForm` is UI→host. In headless runs (`-g`,
411
+ `--json`, CI) the wait resolves `unavailable` by default (configurable to
412
+ auto-`submit` with defaults), so a form never hangs a build — the same policy
413
+ `input/2` uses for its cancel path.
414
+
415
+ ## 8. Registry and routing
416
+
417
+ - `FormRegistry`: `Map<formId, { spec, values, revision, waiters, state }>`.
418
+ - **Monotonic revisions.** `await_form(FormId, Change, Since?)` returns the first
419
+ change with `revision > Since`, else blocks. A submit that lands *before* the
420
+ wait starts is still delivered; a retried wait cannot double-consume.
421
+ - One change resolves all outstanding waiters for that form.
422
+ - **Cancellation:** on turn abort, pending waits resolve `{ action: 'cancelled' }`
423
+ and the form closes.
424
+ - **Timeout:** `await_form(FormId, Change, TimeoutMs)` fails on timeout.
425
+ - **Subagents.** Once a process can render a form, ids must be *routable*, not
426
+ merely unique:
427
+
428
+ | Mode | Behaviour |
429
+ |---|---|
430
+ | L1 (inline) | child sessions share the root registry (they are built from the parent), so `submitForm` reaches the waiting child |
431
+ | L2 (worker) | the worker emits `form` events to the main like process events, and `submitForm` is proxied back through the capability broker |
432
+
433
+ Namespace form ids by owner (session/pid, as pids already are) and carry `source`
434
+ on the event so the UI shows `⚙ delivery [form]`.
435
+
436
+ ## 9. Rendering adapters
437
+
438
+ A capability the host implements, like `setImageSink`:
439
+
440
+ ```ts
441
+ interface FormHost {
442
+ open(formId, spec, values): void;
443
+ update(formId, patch, values, revision): void;
444
+ close(formId, reason): void;
445
+ }
446
+ ```
447
+
448
+ | Layout | TUI (pi-tui) | Web |
449
+ |---|---|---|
450
+ | `row` | `HStack` (stack → `VStack` when narrow) | `display:flex; flex-wrap:wrap` |
451
+ | `column` | `VStack` | `flex-direction:column` |
452
+ | `grid` / `span` | rows of `HStack` with computed cell widths | `grid-template-columns:repeat(N,1fr)`; `span` → `grid-column:span K` |
453
+ | `box` | `Box` (+ title rule) | `<fieldset>` / `<section>` |
454
+ | `tabs` | *no component today* — degrade to stacked boxes (or drive with `SelectList`) | `<details>` / tab strip |
455
+ | `text` / `divider` | `Text` / a rule line | `<p>` / `<hr>` |
456
+ | `spacer` | `Spacer` | margin |
457
+ | `field` | `Input` / `SelectList` / `SettingsList` | `<input>` / `<select>` / … |
458
+
459
+ pi-tui gives `Box`, `HStack`, `VStack`, `Spacer`, `ScrollView`, `Input`,
460
+ `SelectList`, `SettingsList`, `Text` — everything except a tab widget, which is
461
+ why `tabs` degrades in v1 rather than introducing a component.
462
+
463
+ - **Web embedding** renders HTML from the `form` event and POSTs back via
464
+ `submitForm`. Ship a small reference renderer so every embed does not reinvent
465
+ it — that is what makes a protocol useful twice.
466
+ - **`--json`** carries the spec as data; a driver decides what to do.
467
+ - **Plain / headless** prints a summary and resolves `unavailable`.
468
+
469
+ ## 10. Security and limits
470
+
471
+ - **No gate.** The surface is installed into every session (§4). A form is
472
+ inert data rendered by the host; the operations a model performs *after*
473
+ reading it are gated by the ordinary tool policy, which is where the real
474
+ boundary sits.
475
+ - Specs are **inert data**; renderers sanitize. Rendering is not a capability, so
476
+ there is nothing further to gate once the capability is on.
477
+ - **Submitting** leads the agent to call tools, and those already pass through the
478
+ tool policy. A host may also gate the `render_form` tools themselves.
479
+ - Field values are untrusted input to the agent — treat like `user/1`.
480
+ - Cap field count, options, nesting depth, payload size, and revision count; a
481
+ looping model must not allocate unbounded UI state.
482
+
483
+ ## 11. Worked example
484
+
485
+ ```json
486
+ {
487
+ "version": 1,
488
+ "id": "refund",
489
+ "title": "Refund request",
490
+ "description": "Review the charge and decide how to proceed.",
491
+ "fields": [
492
+ { "name": "customer", "type": "object", "properties": [
493
+ { "name": "name", "type": "string", "title": "Customer" },
494
+ { "name": "email", "type": "string", "format": "email", "title": "Email" }
495
+ ]},
496
+ { "name": "amount", "type": "number", "minimum": 0, "required": true },
497
+ { "name": "reason", "type": "string", "widget": "select",
498
+ "enum": ["duplicate_charge", "not_received", "other"],
499
+ "options": [
500
+ { "value": "duplicate_charge", "title": "Charged twice" },
501
+ { "value": "not_received", "title": "Never arrived" },
502
+ { "value": "other", "title": "Other" }
503
+ ], "required": true },
504
+ { "name": "notes", "type": "string", "widget": "textarea", "maxLength": 500 }
505
+ ],
506
+ "initial": { "customer": { "name": "Ana" }, "reason": "duplicate_charge" },
507
+ "layout": [
508
+ { "kind": "box", "title": "Customer", "variant": "fieldset", "children": [
509
+ { "kind": "grid", "columns": 12, "children": [
510
+ { "kind": "field", "field": "customer.name", "span": 7 },
511
+ { "kind": "field", "field": "customer.email", "span": 5 }
512
+ ]}
513
+ ]},
514
+ { "kind": "row", "gap": 2, "children": [
515
+ { "kind": "field", "field": "amount", "widget": "number" },
516
+ { "kind": "field", "field": "reason", "widget": "select" }
517
+ ]},
518
+ { "kind": "text", "variant": "caption", "text": "Notes are shown to the billing team." },
519
+ { "kind": "field", "field": "notes", "widget": "textarea" }
520
+ ],
521
+ "actions": [
522
+ { "name": "submit", "title": "Issue refund", "kind": "primary", "validates": "all" },
523
+ { "name": "escalate", "title": "Escalate to billing", "kind": "default" },
524
+ { "name": "cancel", "title": "Cancel", "kind": "link" }
525
+ ]
526
+ }
527
+ ```
528
+
529
+ On a wide terminal the customer block is two columns and amount/reason sit side by
530
+ side; on a narrow one everything stacks. Same events, same values, same
531
+ validation.
532
+
533
+ ## 12. What is built, and what is next
534
+
535
+ **Built** (the status note at the top is the full list): the primitives
536
+ `form/2`, `form_update/2`, `form_errors/2`, `await_form/2,3`, `close_form/1` plus
537
+ the built-in `tool/2` wrappers; field types `string`, `number`,
538
+ `integer`, `boolean`, plus `object`/`array` groups; `enum`, `required`,
539
+ `minLength`/`maxLength`, `minimum`/`maximum`, `pattern`; layout
540
+ `row`/`column`/`grid`/`box`/`field`/`text`/`divider`; one active form per session;
541
+ `form`/`form_update`/`form_errors`/`await_form`/`close_form` plus the `tool/2`
542
+ wrappers; `form` events + `submitForm`; a TUI panel and a plain-TTY widget;
543
+ headless `unavailable`; host-enforced hard validation and harness advisories.
544
+
545
+ **Next:** in-place field navigation in the TUI panel; forms owned by a subagent
546
+ (forwarding an L2 worker's form to the foreground UI); repeaters (add/remove UI
547
+ for an `array` of `object`); `tabs`; a web reference renderer; cross-turn
548
+ **surfaces**; layout-widget overrides beyond `widget`/`label`.
549
+
550
+ **Out of scope:** conditional layout, computed fields, cross-field validation,
551
+ behavior in actions, styling beyond `columns`/`span`/`gap`/`align`.
552
+
553
+ ## 13. Decisions taken
554
+
555
+ 1. **Tools and primitives** — both. The primitive is the core (a form works with
556
+ no model at all); the tools are ordinary `tool/2` wrappers, so the
557
+ model-facing path is a special case rather than a second implementation.
558
+ 2. **Blocking, within a turn** — `await_form/2` blocks the engine and the turn.
559
+ A form does not outlive its turn; on abort, pending waits resolve
560
+ `{ action: cancelled }`.
561
+ 3. **Repeaters deferred** — values are genuinely nested only under an `array` of
562
+ `object`, which is also where merge-patch's "arrays replace wholesale" rule
563
+ bites. Declaring one is fine; there is no add/remove UI yet.
564
+ 4. **Headless** — a missing UI resolves `unavailable` rather than guessing a
565
+ submit. The CLI's `--answer` is the scripted equivalent.
566
+ 5. **Actions are atoms in DML** — `Change.action == submit`; the host converts at
567
+ the boundary (§2).
568
+
569
+ ---
570
+
571
+ ## Appendix A. Worked examples: the model drives the loop
572
+
573
+ In these two cases the **loop lives in the model**, inside `task/N`, not in a
574
+ Prolog `review_loop/2`. The DML owns policy; the model owns flow.
575
+
576
+ | | Provided by | |
577
+ |---|---|---|
578
+ | Loop: when to generate, validate, finish | the model, inside `task/N` | tool calls |
579
+ | Form tools | built in | `render_form`, `update_form`, `close_form`, `wait_for_form_change`, `validate_form`, `advisories` |
580
+ | **Hard** rules | the harness | `form_validate(Values, Errors)` — Prolog |
581
+ | **Soft** rules | the harness | `form_advisories(Values, Warnings)` — Prolog |
582
+ | Domain facts | the harness | plain facts |
583
+
584
+ Two properties make a model-driven loop safe:
585
+
586
+ 1. **Hard validation is host-enforced on submit, and stays in the host.**
587
+ `form_validate/2` runs when a `validates: hard` action fires; if it returns
588
+ errors the host puts them on the form and keeps waiting (`await_form/2` loops —
589
+ §9), so neither the model nor the harness ever receives a `submit` it can act
590
+ on, and neither has to write the re-show loop. "The model decides when to
591
+ validate" cannot mean "the model may skip validation".
592
+ 2. **`validate_form` / `advisories` are affordances, not the gate.** The model
593
+ calls them for live feedback (`wait_for_form_change` returns `changed`), but
594
+ the authoritative check is the host's.
595
+
596
+ ### A.1 Expense claim (mostly-fixed fields)
597
+
598
+ ```prolog
599
+ vendor(acme). vendor(globex). vendor(initech).
600
+ category(travel). category(meals). category(supplies). category(software).
601
+ currency(eur). currency(usd). currency(gbp).
602
+ claim_limit(travel, 2000). claim_limit(meals, 150).
603
+ claim_limit(supplies, 500). claim_limit(software, 1000).
604
+ today(D) :- get_time(T), format_time(string(D), '%Y-%m-%d', T).
605
+
606
+ %% HARD — deterministic, host-enforced on submit.
607
+ form_validate(Values, Errors) :-
608
+ findall(error(Path, Msg), claim_error(Values, Path, Msg), Errors).
609
+
610
+ claim_error(V, vendor, "is required") :- \+ get_dict(vendor, V, _).
611
+ claim_error(V, vendor, Msg) :-
612
+ get_dict(vendor, V, N), \+ vendor(N),
613
+ format(string(Msg), "unknown vendor: ~w", [N]).
614
+ claim_error(V, category, "is required") :- \+ get_dict(category, V, _).
615
+ claim_error(V, category, Msg) :-
616
+ get_dict(category, V, C), \+ category(C),
617
+ format(string(Msg), "unknown category: ~w", [C]).
618
+ claim_error(V, currency, Msg) :-
619
+ get_dict(currency, V, C), \+ currency(C),
620
+ format(string(Msg), "unsupported currency: ~w", [C]).
621
+ claim_error(V, amount, "is required") :- \+ get_dict(amount, V, _).
622
+ claim_error(V, amount, "must be positive") :-
623
+ get_dict(amount, V, A), \+ (number(A), A > 0).
624
+ claim_error(V, date, "is in the future") :-
625
+ get_dict(date, V, D), today(T), D @> T.
626
+ claim_error(V, amount, Msg) :-
627
+ get_dict(category, V, Cat), get_dict(amount, V, A),
628
+ claim_limit(Cat, Limit), A > Limit,
629
+ format(string(Msg), "exceeds the ~w limit for ~w", [Limit, Cat]).
630
+ claim_error(V, receipt, "is required over 50") :-
631
+ get_dict(amount, V, A), A > 50,
632
+ ( get_dict(receipt, V, R) -> R == "" ; true ).
633
+
634
+ %% SOFT — advisory; exposed as the `advisories` tool.
635
+ form_advisories(Values, Advisories) :-
636
+ findall(warning(Path, Msg), claim_advisory(Values, Path, Msg), Advisories).
637
+
638
+ claim_advisory(V, notes, "the note is thin for an amount this size") :-
639
+ get_dict(notes, V, N), atom_length(N, L), L < 15,
640
+ get_dict(amount, V, A), A > 100.
641
+ claim_advisory(V, amount, Msg) :-
642
+ get_dict(category, V, C), get_dict(amount, V, A), claim_limit(C, Limit),
643
+ A > Limit * 0.8, A =< Limit,
644
+ format(string(Msg), "within 20%% of the ~w limit for ~w", [Limit, C]).
645
+
646
+ agent_main(Context) :-
647
+ system(
648
+ "You are a data-entry assistant collecting an expense claim.\n\
649
+ Loop:\n\
650
+ 1. Design the form and call render_form (vendor, category, amount, \
651
+ currency, date; add a receipt/notes field when the context implies it).\n\
652
+ 2. Call wait_for_form_change. It returns {action, values, changed}.\n\
653
+ 3. action=submit: the rules passed. Optionally call advisories(values) \
654
+ and surface anything material before finishing.\n\
655
+ 4. action=cancel: close_form and finish.\n\
656
+ A refused submit is handled for you: the host shows the errors on the \
657
+ form and wait_for_form_change keeps waiting, so you never see it.\n\
658
+ Never invent values. End with set_result, then finish."),
659
+ with_tools([render_form, update_form, close_form, wait_for_form_change,
660
+ advisories, set_result, finish],
661
+ task("Collect an expense claim. Context: {Context}", string(Summary))),
662
+ answer(Summary).
663
+ ```
664
+
665
+ ### A.2 Customer onboarding (context-dependent fields)
666
+
667
+ Same shape, but the fields depend on the situation: an individual needs name,
668
+ email and country; a business also needs a legal name and, in the EU, a VAT id.
669
+ The model decides what to render; the hard rules do not bend.
670
+
671
+ ```prolog
672
+ country(de). country(fr). country(gb). country(us).
673
+ eu(de). eu(fr).
674
+ vat_prefix(de, "DE"). vat_prefix(fr, "FR"). vat_prefix(gb, "GB").
675
+
676
+ %% HARD
677
+ form_validate(Values, Errors) :-
678
+ findall(error(Path, Msg), onboard_error(Values, Path, Msg), Errors).
679
+
680
+ onboard_error(V, type, "is required") :- \+ get_dict(type, V, _).
681
+ onboard_error(V, name, "is required") :- \+ get_dict(name, V, _).
682
+ onboard_error(V, email, "is required") :- \+ get_dict(email, V, _).
683
+ onboard_error(V, email, "is not a valid email") :-
684
+ get_dict(email, V, E), \+ sub_atom(E, _, _, _, "@").
685
+ onboard_error(V, country, "is required") :- \+ get_dict(country, V, _).
686
+ onboard_error(V, country, Msg) :-
687
+ get_dict(country, V, C), \+ country(C),
688
+ format(string(Msg), "unsupported country: ~w", [C]).
689
+ onboard_error(V, vat_id, "is required for an EU business") :-
690
+ get_dict(type, V, business), get_dict(country, V, C), eu(C),
691
+ ( get_dict(vat_id, V, X) -> X == "" ; true ).
692
+ onboard_error(V, vat_id, Msg) :-
693
+ get_dict(type, V, business), get_dict(vat_id, V, Vat), Vat \== "",
694
+ get_dict(country, V, C), vat_prefix(C, P),
695
+ \+ sub_atom(Vat, 0, 2, _, P),
696
+ format(string(Msg), "a ~w VAT id begins with ~w", [C, P]).
697
+
698
+ %% SOFT
699
+ form_advisories(Values, Advisories) :-
700
+ findall(warning(Path, Msg), onboard_advisory(Values, Path, Msg), Advisories).
701
+
702
+ onboard_advisory(V, email, "a free-mail address on a business account") :-
703
+ get_dict(type, V, business), get_dict(email, V, E),
704
+ ( sub_atom(E, _, _, _, "@gmail.com") ; sub_atom(E, _, _, _, "@outlook.com") ).
705
+ onboard_advisory(V, name, "an unusually short legal name") :-
706
+ get_dict(type, V, business), get_dict(name, V, N), atom_length(N, L), L < 4.
707
+
708
+ agent_main(Context) :-
709
+ system(
710
+ "You onboard a customer. What the form needs depends on the context: an \
711
+ individual needs name, email and country; a business also needs a legal \
712
+ name and, in the EU, a VAT id.\n\
713
+ Loop with render_form -> wait_for_form_change. The host enforces the \
714
+ rules on submit: a refused submit is shown on the form and the wait \
715
+ continues, so you only ever see action=submit or cancel. On submit, call \
716
+ advisories(values) and surface anything material before finishing. Offer \
717
+ `country` as a select. \
718
+ Never invent values."),
719
+ with_tools([render_form, update_form, close_form, wait_for_form_change,
720
+ advisories, set_result, finish],
721
+ task("Onboard a customer. Context: {Context}", string(Summary))),
722
+ answer(Summary).
723
+ ```
724
+
725
+ Both loops are the model's — `agent_main` contains a *prompt* and a *tool scope*,
726
+ not a review loop. The rules are not the model's: `form_validate/2` and
727
+ `form_advisories/2` are ordinary Prolog, unit-testable and deterministic, and the
728
+ hard half is not skippable because the host runs it before a `submit` is
729
+ delivered. Generated and fixed forms are the same mechanism: the model can add or
730
+ rename fields, but a core name it reuses (`amount`, `vat_id`) is what the rules
731
+ read, so a form cannot be shaped to escape them.
732
+
733
+ ---
734
+
735
+ **Lineage.** This is recognisably JSON Schema + a UI schema (react-jsonschema-form,
736
+ JSON Forms) collapsed into one tree, wired to an agent that *acts* on changes
737
+ rather than only re-rendering — which is where it departs from the
738
+ streaming-render "generative UI" approach. The `type`/`widget` split,
739
+ path-addressed values, merge-patch updates, and a `version` field are the four
740
+ decisions that keep it small and evolvable.