deepclause-sdk 0.2.2 → 0.4.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.
- package/README.md +86 -3
- package/dist/agent.d.ts +17 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +148 -42
- package/dist/agent.js.map +1 -1
- package/dist/cli/config.d.ts +12 -12
- package/dist/cli/repl.d.ts +13 -1
- package/dist/cli/repl.d.ts.map +1 -1
- package/dist/cli/repl.js +177 -5
- package/dist/cli/repl.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +4 -0
- package/dist/cli/run.js.map +1 -1
- package/dist/forms/index.d.ts +3 -0
- package/dist/forms/index.d.ts.map +1 -0
- package/dist/forms/index.js +3 -0
- package/dist/forms/index.js.map +1 -0
- package/dist/forms/registry.d.ts +119 -0
- package/dist/forms/registry.d.ts.map +1 -0
- package/dist/forms/registry.js +256 -0
- package/dist/forms/registry.js.map +1 -0
- package/dist/forms/spec.d.ts +193 -0
- package/dist/forms/spec.d.ts.map +1 -0
- package/dist/forms/spec.js +295 -0
- package/dist/forms/spec.js.map +1 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/prolog/loader.d.ts +18 -0
- package/dist/prolog/loader.d.ts.map +1 -1
- package/dist/prolog/loader.js +26 -1
- package/dist/prolog/loader.js.map +1 -1
- package/dist/prolog-src/deepclause_mi.pl +786 -40
- package/dist/repl/agent.d.ts +11 -0
- package/dist/repl/agent.d.ts.map +1 -1
- package/dist/repl/agent.js +23 -9
- package/dist/repl/agent.js.map +1 -1
- package/dist/repl/form-widget.d.ts +56 -0
- package/dist/repl/form-widget.d.ts.map +1 -0
- package/dist/repl/form-widget.js +289 -0
- package/dist/repl/form-widget.js.map +1 -0
- package/dist/repl/harness-tools.d.ts +4 -1
- package/dist/repl/harness-tools.d.ts.map +1 -1
- package/dist/repl/harness-tools.js +15 -58
- package/dist/repl/harness-tools.js.map +1 -1
- package/dist/repl/history.d.ts +24 -1
- package/dist/repl/history.d.ts.map +1 -1
- package/dist/repl/history.js +76 -8
- package/dist/repl/history.js.map +1 -1
- package/dist/repl/index.d.ts +20 -2
- package/dist/repl/index.d.ts.map +1 -1
- package/dist/repl/index.js +9 -0
- package/dist/repl/index.js.map +1 -1
- package/dist/repl/process-backend.d.ts +2 -2
- package/dist/repl/process-backend.d.ts.map +1 -1
- package/dist/repl/process-executor.d.ts +11 -2
- package/dist/repl/process-executor.d.ts.map +1 -1
- package/dist/repl/process-executor.js +67 -18
- package/dist/repl/process-executor.js.map +1 -1
- package/dist/repl/process-worker.d.ts +35 -4
- package/dist/repl/process-worker.d.ts.map +1 -1
- package/dist/repl/process-worker.js +78 -38
- package/dist/repl/process-worker.js.map +1 -1
- package/dist/repl/processes.d.ts +4 -0
- package/dist/repl/processes.d.ts.map +1 -1
- package/dist/repl/processes.js +20 -0
- package/dist/repl/processes.js.map +1 -1
- package/dist/repl/render.d.ts +17 -0
- package/dist/repl/render.d.ts.map +1 -1
- package/dist/repl/render.js +60 -0
- package/dist/repl/render.js.map +1 -1
- package/dist/repl/session.d.ts +143 -7
- package/dist/repl/session.d.ts.map +1 -1
- package/dist/repl/session.js +317 -20
- package/dist/repl/session.js.map +1 -1
- package/dist/repl/tui/form-view.d.ts +17 -0
- package/dist/repl/tui/form-view.d.ts.map +1 -0
- package/dist/repl/tui/form-view.js +25 -0
- package/dist/repl/tui/form-view.js.map +1 -0
- package/dist/repl/tui/renderer.d.ts +4 -0
- package/dist/repl/tui/renderer.d.ts.map +1 -1
- package/dist/repl/tui/renderer.js +12 -0
- package/dist/repl/tui/renderer.js.map +1 -1
- package/dist/repl/worker-host.d.ts +16 -2
- package/dist/repl/worker-host.d.ts.map +1 -1
- package/dist/repl/worker-host.js +38 -6
- package/dist/repl/worker-host.js.map +1 -1
- package/dist/repl/worker-protocol.d.ts +14 -0
- package/dist/repl/worker-protocol.d.ts.map +1 -1
- package/dist/repl/worker-protocol.js +8 -1
- package/dist/repl/worker-protocol.js.map +1 -1
- package/dist/runner.d.ts +69 -1
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +381 -44
- package/dist/runner.js.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +12 -0
- package/dist/sdk.js.map +1 -1
- package/dist/system/assets/docs/AGENT_PROMPT.md +452 -18
- package/dist/system/assets/docs/CHAT_PROMPT.md +28 -0
- package/dist/system/assets/docs/DML_AUTHORING.md +8 -4
- package/dist/system/assets/docs/DML_FORMS.md +740 -0
- package/dist/system/assets/docs/DML_REFERENCE.md +239 -4
- package/dist/system/assets/docs/HARNESS_EXAMPLES.md +94 -0
- package/dist/system/assets/index.d.ts +2 -2
- package/dist/system/assets/index.d.ts.map +1 -1
- package/dist/system/assets/index.js +4 -0
- package/dist/system/assets/index.js.map +1 -1
- package/dist/system/environment.d.ts +6 -0
- package/dist/system/environment.d.ts.map +1 -1
- package/dist/system/environment.js +5 -4
- package/dist/system/environment.js.map +1 -1
- package/dist/types.d.ts +48 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -2
- package/src/prolog-src/deepclause_mi.pl +786 -40
|
@@ -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.
|