@falai/agent 4.0.0-alpha.12 → 4.0.0-alpha.13

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 (55) hide show
  1. package/dist/cjs/core/FlowSpec.d.ts +2 -0
  2. package/dist/cjs/core/FlowSpec.d.ts.map +1 -1
  3. package/dist/cjs/core/FlowSpec.js +25 -2
  4. package/dist/cjs/core/FlowSpec.js.map +1 -1
  5. package/dist/cjs/core/Runner.d.ts +6 -0
  6. package/dist/cjs/core/Runner.d.ts.map +1 -1
  7. package/dist/cjs/core/Runner.js +63 -12
  8. package/dist/cjs/core/Runner.js.map +1 -1
  9. package/dist/cjs/types/flow.d.ts +14 -0
  10. package/dist/cjs/types/flow.d.ts.map +1 -1
  11. package/dist/cjs/types/session.d.ts +1 -1
  12. package/dist/cjs/types/session.d.ts.map +1 -1
  13. package/dist/cjs/utils/outcomes.d.ts +1 -0
  14. package/dist/cjs/utils/outcomes.d.ts.map +1 -1
  15. package/dist/cjs/utils/outcomes.js +1 -0
  16. package/dist/cjs/utils/outcomes.js.map +1 -1
  17. package/dist/cjs/utils/schema.d.ts +3 -3
  18. package/dist/cjs/utils/schema.d.ts.map +1 -1
  19. package/dist/cjs/utils/schema.js +4 -3
  20. package/dist/cjs/utils/schema.js.map +1 -1
  21. package/dist/core/FlowSpec.d.ts +2 -0
  22. package/dist/core/FlowSpec.d.ts.map +1 -1
  23. package/dist/core/FlowSpec.js +26 -3
  24. package/dist/core/FlowSpec.js.map +1 -1
  25. package/dist/core/Runner.d.ts +6 -0
  26. package/dist/core/Runner.d.ts.map +1 -1
  27. package/dist/core/Runner.js +63 -12
  28. package/dist/core/Runner.js.map +1 -1
  29. package/dist/types/flow.d.ts +14 -0
  30. package/dist/types/flow.d.ts.map +1 -1
  31. package/dist/types/session.d.ts +1 -1
  32. package/dist/types/session.d.ts.map +1 -1
  33. package/dist/utils/outcomes.d.ts +1 -0
  34. package/dist/utils/outcomes.d.ts.map +1 -1
  35. package/dist/utils/outcomes.js +1 -0
  36. package/dist/utils/outcomes.js.map +1 -1
  37. package/dist/utils/schema.d.ts +3 -3
  38. package/dist/utils/schema.d.ts.map +1 -1
  39. package/dist/utils/schema.js +4 -3
  40. package/dist/utils/schema.js.map +1 -1
  41. package/docs/concepts/collection.md +40 -5
  42. package/docs/concepts/pipeline.md +3 -2
  43. package/docs/reference/fields.md +5 -3
  44. package/docs/reference/flow-spec.md +6 -3
  45. package/docs/reference/flow.md +7 -2
  46. package/docs/reference/outcomes.md +2 -1
  47. package/docs/reference/step.md +5 -2
  48. package/docs/rfc/v4-one-flow.md +2 -0
  49. package/package.json +1 -1
  50. package/src/core/FlowSpec.ts +32 -4
  51. package/src/core/Runner.ts +56 -10
  52. package/src/types/flow.ts +14 -0
  53. package/src/types/session.ts +1 -0
  54. package/src/utils/outcomes.ts +1 -0
  55. package/src/utils/schema.ts +4 -3
@@ -7,7 +7,7 @@ order: 4
7
7
 
8
8
  # Field collection
9
9
 
10
- A field is one piece of data the conversation collects: a name, a company, a budget, a yes or no. You declare each field once, on the agent, with how to ask for it. Steps say which fields they collect. The model asks and extracts. Code decides what is still missing.
10
+ A field is one piece of data the conversation collects: a name, a company, a budget, a yes or no. You declare each field once, on the agent, with how to ask for it. A flow lists the fields it needs, and its steps say which to ask and when. The model asks and extracts. Code decides what is still missing.
11
11
 
12
12
  ## Declared once
13
13
 
@@ -41,12 +41,45 @@ type Data = DataOf<typeof f>;
41
41
  |---|---|
42
42
  | `type` | `'string'`, `'number'`, `'integer'` or `'boolean'`. Values are coerced to it on the way in. |
43
43
  | `enum` | The allowed values. A value outside the list is dropped. It becomes a literal union in `Data`. |
44
+ | `label` | The name a person reads, in an editor or next to a collected value. The model never sees it. |
44
45
  | `description` | What the field means, for the model. |
45
46
  | `ask` | How the model should ask for it. A step may override it. |
46
47
  | `extract` | Where a value may come from: `'anywhere'` or `'asked'`. The default depends on `type`, below. |
47
48
 
48
49
  `f.fields()` binds the data type, so `collect`, `ask`, `clearOnStart`, `{ step, clear }`, `if: { equals }` and an action's `ctx.set()` are all checked against these field names at compile time. The collected values live in `session.data` as a `Partial<Data>`.
49
50
 
51
+ ## A flow's data, a step's questions
52
+
53
+ A scheduling flow needs one set of fields and a triage flow another. The flow's `collect` lists the fields it needs. Each talk step's `collect` says which of them to ask now, in what order: one field, or several when the step's prompt asks them together.
54
+
55
+ ```ts
56
+ import { falai } from "@falai/agent";
57
+
58
+ const f = falai().fields({
59
+ nome: { type: "string", label: "Nome", ask: "Pergunte o nome." },
60
+ dia: { type: "string", label: "Dia", ask: "Pergunte qual dia fica melhor." },
61
+ orcamento: { type: "number", label: "Orçamento" },
62
+ });
63
+
64
+ const agenda = f.flow({
65
+ id: "agenda",
66
+ name: "Agendamento",
67
+ on: [{ message: ["quer agendar uma visita"] }],
68
+ collect: ["nome", "dia", "orcamento"],
69
+ steps: [
70
+ { id: "quem", collect: ["nome"], question: "Claro! Qual é o seu nome?" },
71
+ { id: "quando", prompt: "Ofereça terça ou quinta.", collect: ["dia"] },
72
+ { id: "fim", say: "Combinado, {{data.nome}}. Até {{data.dia}}." },
73
+ ],
74
+ });
75
+
76
+ export { agenda };
77
+ ```
78
+
79
+ No step asks for `orcamento`. It is still on the flow's list, so when the customer mentions a budget while this flow holds the conversation, the value is noted. A field on the list that only an answer can fill (`extract: 'asked'`, every boolean by default) and that no step asks can never be filled; `validateFlow` warns about it.
80
+
81
+ Step one asks with fixed text: `question`. Its first ask goes out word for word, with no model call. It goes out only when every field the step collects is still missing. If the customer's first message already gave the name, the step is skipped. A later ask is the model's own wording, so a customer who replies with a question gets an answer.
82
+
50
83
  ## Known and pending
51
84
 
52
85
  A field is **known** when its value is not `undefined`, `null` or `''`. Anything else is unknown. There is no "asked but refused" state, only a count.
@@ -69,7 +102,7 @@ Four writers reach `session.data`. Two are the model, two are your code.
69
102
 
70
103
  | Writer | Which fields | When |
71
104
  |---|---|---|
72
- | The understand call | Unknown fields with `extract: 'anywhere'` listed by any talk step of the floor's flow or of a candidate `message` flow | On a message, before runs move |
105
+ | The understand call | Unknown fields with `extract: 'anywhere'` that the floor's flow or a candidate `message` flow lists, in its own `collect` or a talk step's. With nobody on the floor, also the catch-all's (`message: []`) | On a message, before runs move |
73
106
  | The speak call | The speaking step's pending fields, whatever their `extract`, in the envelope `{ message, ...fields }` | When the step speaks |
74
107
  | A tool's `data` | Whatever the tool returns | During a speak round, written as given |
75
108
  | An action's `ctx.set(patch)` | Whatever the action writes | During a `do` step, written as given |
@@ -110,7 +143,7 @@ This split also decides which call spends tokens on what. On a message, the unde
110
143
  { kind: 'collect', status: 'skipped', code: 'max-asks', detail: 'orcamento' }
111
144
  ```
112
145
 
113
- One line per field that ran out of asks, with the field's slug in `detail`. `maxAsks: 1` means "ask once, do not insist". The field stays unknown: a later step may still collect it, and a later run of the flow starts the count again, because `asked` lives on the run.
146
+ One line per field that ran out of asks, with the field's slug in `detail`. `maxAsks: 1` means "ask once, do not insist". The field stays unknown: a later step may still collect it, and a later run of the flow starts the count again, because `asked` lives on the run. A step's fixed `question` counts as one ask. `{ step, clear }` resets the count of the fields it clears, so a confirmation loop asks from scratch each round.
114
147
 
115
148
  ## Known fields are never re-extracted
116
149
 
@@ -157,11 +190,13 @@ Three layers of text shape the question, from general to specific.
157
190
  2. The step's `ask: { orcamento: '…' }`: wins over the field's, for that step only.
158
191
  3. The step's `prompt`: the guideline for the whole reply. Without one, the default is "Collect what is still missing below, in the flow of the conversation, one or two things per message."
159
192
 
160
- All three are templates: `{{data.x}}`, `{{context.x}}` and `{{input.x}}` are filled in before the model reads them. The model sees every pending field of the step with its wording, in collect order, and is told to ask at the pace the prompt sets and to take any value the customer's message already answers.
193
+ A step's `question` skips all three for the first ask: it is the exact text the customer reads. Every later ask goes back to the three layers.
194
+
195
+ All four are templates: `{{data.x}}`, `{{context.x}}` and `{{input.x}}` are filled in before the model reads them. The model sees every pending field of the step with its wording, in collect order, and is told to ask at the pace the prompt sets and to take any value the customer's message already answers.
161
196
 
162
197
  ## What the provider sees
163
198
 
164
- `toWireSchema` in `src/utils/schema.ts` is the only way a field definition reaches a provider. It keeps `type`, `description` and `enum` and strips `ask`, `extract` and `optional`: those are for the framework, not the model. The result is a closed JSON schema (`additionalProperties: false`). In both envelopes every property is required and nullable, so the model must answer each field with a value or `null`. A field name that is not a legal property name for the provider, or is spelled `message`, travels under an alias and is mapped back on the way out.
199
+ `toWireSchema` in `src/utils/schema.ts` is the only way a field definition reaches a provider. It keeps `type`, `description` and `enum` and strips `label`, `ask`, `extract` and `optional`: those are for the framework, not the model. The result is a closed JSON schema (`additionalProperties: false`). In both envelopes every property is required and nullable, so the model must answer each field with a value or `null`. A field name that is not a legal property name for the provider, or is spelled `message`, travels under an alias and is mapped back on the way out.
165
200
 
166
201
  ## Where next
167
202
 
@@ -62,7 +62,7 @@ Code first works out what there is to judge:
62
62
  - **Candidates**: the flow holding the floor, whatever its trigger, plus every `message` flow with a non-empty phrase list whose `if` holds and whose `repeat` allows a start, in flow order. `message: []` catch-alls are never scored.
63
63
  - **Mentions**: every `mention` flow with a non-empty list whose `repeat` allows a start.
64
64
  - **Branches**: the `when` branches of the asking step.
65
- - **Fields**: every unknown field with `extract: 'anywhere'` listed by any talk step of the floor's flow or of a candidate flow.
65
+ - **Fields**: every unknown field with `extract: 'anywhere'` that the floor's flow or a candidate flow lists, in its own `collect` or a talk step's. With nobody on the floor, the catch-all that would take the message adds its fields too, because an opening message often says the most. The fields its first step asks are read by that step's own speak call, so on their own they are not worth a call. A first step with a fixed `question` has no speak call, so its fields do count.
66
66
 
67
67
  Then the shortcuts, each worth zero calls:
68
68
 
@@ -140,11 +140,12 @@ Every row but the last is asserted by a scenario in `tests/scenarios/`; the comp
140
140
  | Turn | Calls | Why | Scenario |
141
141
  |---|---|---|---|
142
142
  | A message with a floor holder, several candidate flows, or one candidate that a catch-all or the idle speaker could stand in for | 2 | understand, then speak | S1, S13 |
143
- | A message with nothing to judge: a catch-all alone, a floor holder alone, or one flow with `idle: 'silent'` and no catch-all | 1 | speak only | S0, S1, S8 |
143
+ | A message with nothing to judge: a catch-all alone with nothing to learn beyond what its first step asks, a floor holder alone, or one flow with `idle: 'silent'` and no catch-all | 1 | speak only | S0, S1, S8, S14 |
144
144
  | A message with no flows, answered by the idle speaker | 1 | speak only | S9 |
145
145
  | A message where a mention flow's `say` answers | 1 | understand only; the floor's talk is skipped | S4 |
146
146
  | Each tool round | +1 | one more speak call | S8, S9 |
147
147
  | A wake or start that reaches a talk step | 1 | speak only; there is no message to understand | S2, S5, S12 |
148
+ | A step's first ask with a fixed `question` | 0 | the text goes out as written | S14 |
148
149
  | A wake or start that runs only `do`, `wait` and `if` steps | 0 | code only | S5 (the start; its defer test covers the wake) |
149
150
  | Any input under `silenced` (a plain reason) | 0 | `do` steps run, nobody speaks; `{ reason, understand: true }` still spends the understand call | S2, S12 |
150
151
  | A message with `idle: 'silent'` and no eligible flow | 0 | nothing to judge, nobody speaks | S9 |
@@ -21,6 +21,7 @@ interface ScalarDef<T extends ScalarType = ScalarType> {
21
21
  }
22
22
 
23
23
  interface FieldDef<T extends ScalarType = ScalarType> extends ScalarDef<T> {
24
+ label?: string;
24
25
  ask?: string;
25
26
  extract?: "anywhere" | "asked";
26
27
  }
@@ -37,6 +38,7 @@ type DataOf<T extends { fields: FieldDefs }> = InferData<T["fields"]>;
37
38
  |---|---|---|---|
38
39
  | `type` | `ScalarType` | required | `'string'`, `'number'`, `'integer'` or `'boolean'`. |
39
40
  | `enum` | `readonly (string \| number)[]` | none | The allowed values. A value outside the list is dropped (`code: 'not-in-enum'`). In the data type the field becomes the literal union. |
41
+ | `label` | `string` | none | The name a person reads, in an editor or next to a collected value. Never sent to the model. |
40
42
  | `description` | `string` | none | What the field is. Sent to the model with the field's type and options. |
41
43
  | `ask` | `string` | none | How the model should ask for it. Sent to the speak call as "How to ask" while the field is pending. A talk step's own `ask` overrides it. A template: `{{data.x}}` and `{{context.x}}` are filled in. |
42
44
  | `extract` | `'anywhere' \| 'asked'` | `'anywhere'` for string, number and integer; `'asked'` for boolean | Where a value may be taken from. `'anywhere'`: any message from the customer, whether or not the field was asked. `'asked'`: only the reply to the step that lists the field, so a stray "sim" never confirms anything. |
@@ -55,7 +57,7 @@ Properties are readonly — `fields()` takes the definitions as a `const` type,
55
57
 
56
58
  **Known.** A field is known when its value is not `undefined`, `null` or `''`. Known fields are never asked again and never re-extracted; both calls list them under "Already known".
57
59
 
58
- **Which call extracts what.** The understand call, on a message turn, extracts every unknown `'anywhere'` field named in a `collect` of the flow holding the floor and of every eligible message flow, in one envelope. The speak call extracts the pending fields of the step that speaks, `'asked'` ones included, in the same call that phrases the reply. Tools write `data` and actions call `ctx.set()`; those values are written as given, with no check.
60
+ **Which call extracts what.** The understand call, on a message turn, extracts every unknown `'anywhere'` field that the flow holding the floor or an eligible message flow lists, in the flow's own `collect` or a talk step's, in one envelope. With nobody on the floor, the catch-all's fields are read too. The speak call extracts the pending fields of the step that speaks, `'asked'` ones included, in the same call that phrases the reply. Tools write `data` and actions call `ctx.set()`; those values are written as given, with no check.
59
61
 
60
62
  **Coercion.** A raw value from the model goes through `coerceField(def, raw)` before it is written:
61
63
 
@@ -68,9 +70,9 @@ Properties are readonly — `fields()` takes the definitions as a `const` type,
68
70
 
69
71
  Anything else is dropped with the outcome line `code: 'bad-value'`. A value outside `enum` is dropped with `code: 'not-in-enum'`. A value for a slug that is not a field is dropped with `code: 'unknown-field'`. Dropped values leave a `collect` outcome with status `skipped` and no run id; the field stays pending and is asked again.
70
72
 
71
- **What reaches the provider.** `toWireSchema(defs)` turns definitions into the JSON schema the model must fill: a closed object (`additionalProperties: false`) with one property per field carrying `type`, `description` and `enum`, and nothing else. `ask` and `extract` are stripped; they steer the framework, not the schema. In the envelope style used by both calls every property is required and nullable (`type: [type, 'null']`), so the model answers `null` for what the customer did not give. The prompt describes each field once more in words (`nome (string) [a | b]: description`) and, in the speak call, adds the pending field's "How to ask".
73
+ **What reaches the provider.** `toWireSchema(defs)` turns definitions into the JSON schema the model must fill: a closed object (`additionalProperties: false`) with one property per field carrying `type`, `description` and `enum`, and nothing else. `label`, `ask` and `extract` are stripped; they are for people and the framework, not the schema. In the envelope style used by both calls every property is required and nullable (`type: [type, 'null']`), so the model answers `null` for what the customer did not give. The prompt describes each field once more in words (`nome (string) [a | b]: description`) and, in the speak call, adds the pending field's "How to ask".
72
74
 
73
- **Clearing.** `{ step, clear: ['x'] }` on a `then`/`else`/branch and `clearOnStart` on a flow delete the field from `session.data`, so the next step that collects it asks again. `maxAsks` on a talk step (default 3) is the other way a field stops being pending: it is skipped for that run with `code: 'max-asks'`, the field in `detail`.
75
+ **Clearing.** `{ step, clear: ['x'] }` on a `then`/`else`/branch and `clearOnStart` on a flow delete the field from `session.data`, so the next step that collects it asks again. `{ step, clear }` also resets how many times the run asked it. `maxAsks` on a talk step (default 3) is the other way a field stops being pending: it is skipped for that run with `code: 'max-asks'`, the field in `detail`.
74
76
 
75
77
  **Action parameters** use the sibling type `ParamDef`: a `ScalarDef` plus `optional?: true`, or `{ type: 'array', items: ScalarDef }`. `InferParams<P>` gives the `with` shape. They share `toWireSchema` and the same strictness at construction. See [Actions, events and conditions](actions-events-conditions.md).
76
78
 
@@ -23,6 +23,7 @@ interface FlowSpec {
23
23
  on?: TriggerSpec[];
24
24
  anchor?: string;
25
25
  while?: ConditionSpec;
26
+ collect?: string[];
26
27
  clearOnStart?: string[];
27
28
  steps: StepSpec[];
28
29
  onEnd?: "end" | "stay" | "reset";
@@ -33,7 +34,7 @@ interface FlowSpec {
33
34
  type StepSpec = StepBase &
34
35
  (
35
36
  | { kind: "prompt"; prompt: Template; ask?; maxAsks?; branches?: BranchSpec[]; tools?; instructions?: InstructionSpec[] }
36
- | { kind: "collect"; collect: string[]; prompt?: Template; ask?; maxAsks?; branches?: BranchSpec[]; tools?; instructions?: InstructionSpec[] }
37
+ | { kind: "collect"; collect: string[]; prompt?: Template; question?: Template; ask?; maxAsks?; branches?: BranchSpec[]; tools?; instructions?: InstructionSpec[] }
37
38
  | { kind: "say"; say: Template; media?: { slug: string }; once?: boolean }
38
39
  | { kind: "do"; do: string; with?: Record<string, unknown>; onFail?: Next }
39
40
  | { kind: "wait"; wait: Duration; businessHours?: boolean; else?: Next; branches?: BranchSpec[] }
@@ -120,7 +121,7 @@ Every message has the form `[FlowConfigurationError] <where>: <what>. <fix>`, wh
120
121
  | Reserved step id | `uses the reserved id "end"` | "end" ends the run; pick another id. |
121
122
  | Duplicate step id | `duplicates an earlier step id` | Give each step its own id. |
122
123
  | Triggers, no steps | `has triggers but no steps` | Add at least one step or remove `on`. |
123
- | Unknown field | `unknown field "x" in collect` (also `ask`, `clearOnStart`, `then.clear`, `while.equals`, `if.known`, …) | Add it to the agent's fields or fix the slug. |
124
+ | Unknown field | `unknown field "x" in collect` (the flow's or a step's; also `ask`, `clearOnStart`, `then.clear`, `while.equals`, `if.known`, …) | Add it to the agent's fields or fix the slug. |
124
125
  | Unknown tool | `unknown tool "x"` (flow or step `tools`) | Register it in the agent's tools or fix the name. |
125
126
  | Unknown action | `unknown action "x"` | Register it in actions or fix the name. |
126
127
  | Unknown event | `unknown event "x"` (trigger) or `unknown event "x" in wait` | Register it in events or fix the name. |
@@ -136,6 +137,7 @@ Every message has the form `[FlowConfigurationError] <where>: <what>. <fix>`, wh
136
137
  | Extra parameter | `action "notify" has no parameter "to"` | Remove it or fix the name. |
137
138
  | Branch without a test | `branches[0] has neither when nor if` | Give the branch an AI condition (when) or a code one (if). |
138
139
  | Backward `if` with no `else` | `"if" jumps back to "quem" with no else` | Add else so the false branch has somewhere to go. |
140
+ | Fixed question, nothing to ask | `has a question but collects nothing` | A fixed question asks for fields: add collect, or send the text with a say step. |
139
141
 
140
142
  Parameter values are checked strictly: `"3"` is not a number, `3.5` is not an integer, and an `enum` must contain the value unless the string holds `{{`, because a template's value is only known at run time.
141
143
 
@@ -146,7 +148,8 @@ Two checks live in the agent constructor rather than in `validateFlow`: `flow "x
146
148
  | Warning | Why |
147
149
  |---|---|
148
150
  | `flow "f", step "s": then jumps back to "quem" without clear; the fields collected since stay known and those steps skip. Add clear: [...] to re-ask them.` | A `then`, `else`, `onFail` or branch target points at the same or an earlier step and clears nothing, so a collect step it lands on is skipped with `code: 'already-known'`. |
149
- | `flow "f", step "s": collects "nome", "empresa" with no prompt and no ask; the model has nothing to go on. Add a prompt or an ask per field.` | A collect step with no `prompt`, where no listed field has an `ask` on the step or on the agent. |
151
+ | `flow "f", step "s": collects "nome", "empresa" with no prompt and no ask; the model has nothing to go on. Add a prompt or an ask per field.` | A collect step with no `prompt` and no `question`, where no listed field has an `ask` on the step or on the agent. |
152
+ | `flow "f": collect lists "confirmado", which is only taken from the answer to a step that asks it, and no step does. Add it to a step's collect, or set extract: 'anywhere' on the field.` | A field in the flow's `collect` with `extract: 'asked'` (every boolean, by default) that no step's `collect` lists, so nothing can ever fill it. |
150
153
 
151
154
  ## flowSpecSchema
152
155
 
@@ -19,6 +19,7 @@ interface Flow<C = unknown, D = unknown> {
19
19
  on?: Trigger<C, D>[];
20
20
  anchor?: string;
21
21
  while?: Pred<C, D>;
22
+ collect?: (keyof D & string)[];
22
23
  clearOnStart?: (keyof D & string)[];
23
24
  steps: Step<C, D>[];
24
25
  onEnd?: "end" | "stay" | "reset";
@@ -37,6 +38,7 @@ interface Flow<C = unknown, D = unknown> {
37
38
  | `on` | `Trigger<C, D>[]` | none | What starts a run. Absent or empty: only `turn({ start })` or another flow's `then: { flow }` starts it. See [Trigger](trigger.md). |
38
39
  | `anchor` | `string` | `'session'` | What a run is keyed to. `'session'` uses the session id. Any other name reads `input.anchors[name].key`, and falls back to the session id when the host did not pass that anchor. |
39
40
  | `while` | `Pred<C, D>` | the trigger's `if` | Re-checked before the run moves. When it stops holding, the run ends with `code: 'premise-changed'`. |
41
+ | `collect` | `(keyof D & string)[]` | none | The data this flow needs, as agent field slugs. Talk steps' `collect` says which of it to ask, and in what order. |
40
42
  | `clearOnStart` | `(keyof D & string)[]` | none | Fields forgotten when a run of this flow starts, so a second run asks for them again. |
41
43
  | `steps` | `Step<C, D>[]` | required | In order. A run enters `steps[0]` and moves to the next step unless `then` says otherwise. See [Step](step.md). |
42
44
  | `onEnd` | `'end' \| 'stay' \| 'reset'` | `'end'` | What the run does after its last step. |
@@ -59,6 +61,8 @@ It enters `steps[0]` in the same turn unless the trigger has `after`. Keys and s
59
61
 
60
62
  **`while`.** Checked every time the run is about to move: at the start of each turn's run phase for a running run, and when an asking run resumes on a message. A parked run (`waiting`) or a suspended one is not checked until it moves again. Without `while`, the check is the trigger's `if`: the run holds while any trigger of the same kind as the one that started it would still fire. A run started by `start` or by another flow has no such trigger, so without `while` it always holds. A silence run also ends, with `code: 'customer-replied'`, when a wake finds that the customer wrote after the run started.
61
63
 
64
+ **`collect`.** The flow's data is its `collect` plus every field its talk steps collect. While the flow holds the conversation, or could take it on this message, the understand call notes any of it the customer gives that is still unknown and has `extract: 'anywhere'`. A field no step asks is still noted that way; it is just never asked for. When nobody holds the conversation, the catch-all (`message: []`) that would take the message counts as a flow that could take it. See [Field collection](../concepts/collection.md).
65
+
62
66
  **`clearOnStart`.** Applied at start for every trigger kind, `start` and `{ flow }` chains included. Not applied when `onEnd: 'reset'` restarts the flow: reset keeps the data.
63
67
 
64
68
  **`onEnd`.**
@@ -77,7 +81,8 @@ It enters `steps[0]` in the same turn unless the trigger has `after`. Keys and s
77
81
  - no `id`, or `steps` is not a list
78
82
  - a step with no `id`, the id `'end'`, or an id used twice
79
83
  - triggers with zero steps
80
- - an unknown field slug in `clearOnStart`, `collect`, `ask`, `equals`, `known` or a `clear` list
84
+ - an unknown field slug in the flow's `collect`, `clearOnStart`, a step's `collect`, `ask`, `equals`, `known` or a `clear` list
85
+ - a `question` on a talk step that collects nothing
81
86
  - an unknown action in `do`; a `with` that misses a required parameter, names one the action does not have, or gives a value of the wrong type (`with` values are not coerced; a `{{template}}` string is accepted for any enum)
82
87
  - an unknown event in a trigger or in `wait: { event }`
83
88
  - an unknown condition name, or a malformed built-in (`equals` not an object, `known` not a list, `silenced` not a boolean); an `equals` value whose type does not match the field
@@ -87,7 +92,7 @@ It enters `steps[0]` in the same turn unless the trigger has `after`. Keys and s
87
92
  - a branch with neither `when` nor `if`
88
93
  - an `if` step whose `then` jumps backward with no `else`
89
94
 
90
- It returns warnings, logged by the agent, for two things that run but probably not as intended: a jump backward without `clear` (the fields collected since stay known, so those steps skip), and a `collect` step with no `prompt` and no `ask` on any of its fields.
95
+ It returns warnings, logged by the agent, for three things that run but probably not as intended: a jump backward without `clear` (the fields collected since stay known, so those steps skip), a `collect` step with no `prompt`, no `question` and no `ask` on any of its fields, and a field in the flow's `collect` that only an answer can fill (`extract: 'asked'`) when no step asks it.
91
96
 
92
97
  `toSpec(flow)` throws `FlowConfigurationError` when a predicate is a function, because a function cannot be stored as JSON.
93
98
 
@@ -25,7 +25,7 @@ type StepOutcomeCode =
25
25
  // A run ended early
26
26
  | "step-loop" | "step-gone" | "customer-replied" | "premise-changed" | "silenced"
27
27
  // A step
28
- | "already-known" | "another-reply" | "already-sent" | "branch" | "max-asks"
28
+ | "already-known" | "asked-fixed" | "another-reply" | "already-sent" | "branch" | "max-asks"
29
29
  | "inline-delay" | "awaiting-trigger" | "awaiting-event" | "event-arrived"
30
30
  | "no-event" | "replied" | "no-reply"
31
31
  // A host action
@@ -134,6 +134,7 @@ Grouped by what produced it. `kind` and `status` are given as `kind / status`. A
134
134
  |---|---|---|---|
135
135
  | `prompt` or `collect / ok` | none; `llmCalls` set | The speak call answered. The run is asking if fields are still pending, else it moved. | none. This line never carries `next`, even when the run moves on; the lines that follow show where it went |
136
136
  | `collect / skipped` | `already-known` | The step was entered and every field it collects was already known (or at `maxAsks`). No call. | `then` |
137
+ | `collect / ok` | `asked-fixed` | The step's `question` went out word for word as its first ask. No call; the run is asking. | |
137
138
  | `collect / ok` | none, no `llmCalls` | An asking step whose remaining fields were all known when the customer's next message resumed it: the understand call filled them in from the message, or an action's `ctx.set()` or a tool's `data` had written them since the step last asked. It moved without speaking. | `then` |
138
139
  | `collect / skipped` | `max-asks`, `detail` = the field slug | One line per field still unknown when the step moves on because that field reached `maxAsks` (default 3). | |
139
140
  | `prompt` or `collect / ok` | `branch` | A branch of the asking step fired: an `if` branch held, or the model answered `when` with true. | the branch's `then` |
@@ -44,6 +44,7 @@ The model speaks. A guideline, fields to collect, or both.
44
44
  ```ts fragment
45
45
  type TalkStep<C, D> = ({ prompt: Template; collect?: (keyof D & string)[] } | { collect: (keyof D & string)[]; prompt?: Template }) & {
46
46
  ask?: Partial<Record<keyof D & string, string>>;
47
+ question?: Template;
47
48
  maxAsks?: number;
48
49
  branches?: Branch<C, D>[];
49
50
  tools?: string[];
@@ -54,8 +55,9 @@ type TalkStep<C, D> = ({ prompt: Template; collect?: (keyof D & string)[] } | {
54
55
  | Field | Type | Default | Meaning |
55
56
  |---|---|---|---|
56
57
  | `prompt` | `Template` | "Collect what is still missing below, in the flow of the conversation, one or two things per message." | The guideline for the reply. Required when there is no `collect`. |
57
- | `collect` | `(keyof D & string)[]` | none | Fields to collect. The step is done when they are known. |
58
+ | `collect` | `(keyof D & string)[]` | none | Fields to ask for now, in this order. The step is done when they are known. The flow's own `collect` lists everything it needs; see [Flow](flow.md). |
58
59
  | `ask` | `Partial<Record<slug, string>>` | the field's own `ask` | Per-flow wording for a field. |
60
+ | `question` | `Template` | none | A fixed first question, sent word for word with no model call. Needs `collect`. |
59
61
  | `maxAsks` | `number` | `3` | Times a field may be asked before it is skipped (`code: 'max-asks'`, the field in `detail`). |
60
62
  | `branches` | `Branch<C, D>[]` | none | Exits judged while the step asks. See [Branches](branches.md). |
61
63
  | `tools` | `string[]` | the flow's `tools`, else all | Tools the model may call from this step. |
@@ -64,6 +66,7 @@ type TalkStep<C, D> = ({ prompt: Template; collect?: (keyof D & string)[] } | {
64
66
  - Pending fields are `collect` minus the known ones minus those at `maxAsks`, in `collect` order. A step the run enters whose pending list is empty is skipped with no model call (`code: 'already-known'`) and the run follows `then`. When the asking run resumes and the message filled the last field, the same move is logged `ok` with no detail.
65
67
  - Reaching a talk step suspends any other run that was asking; this run becomes the asker and holds the floor. It speaks in this turn if the speak call has not happened yet; otherwise it speaks on the next message.
66
68
  - The speak call returns the message plus one value per pending field. Values are validated and written; each field still pending is counted as asked once more. With pending fields left the run stays asking. With none left, or with no `collect` at all, the run follows `then` in the same turn.
69
+ - With `question`, the step's first ask is that text, as a `kind: 'verbatim'` message with no speak call (`code: 'asked-fixed'`). It goes out only when every field in `collect` is still pending and none was asked yet, and never on a run that stays (`onEnd: 'stay'`). Reached after this turn's speak call, it goes out in the same turn, the way a `say` does. It counts as one ask of each field. Every later ask is the model's own wording, so a customer who asks something back gets an answer; `maxAsks` still applies. A `{ step, clear }` that clears the step's fields also clears their ask count, so the question goes out again.
67
70
  - With `silenced` set, a talk step ends the run with `code: 'silenced'` (`detail` = your reason); a run that was already asking stays asking instead.
68
71
  - Outcome kind: `collect` when `collect` is non-empty, else `prompt`.
69
72
 
@@ -154,7 +157,7 @@ The code forks. No model call.
154
157
  |---|---|
155
158
  | `'passo'` | Jump to that step id. |
156
159
  | `'end'` | Finish the run here, exactly as running past the last step does — `onEnd` still decides: `'end'` ends it, `'stay'` goes back to the last talk step and answers every message from there, `'reset'` starts a fresh run. `'end'` is reserved: no step may use it as an id. |
157
- | `{ step: 'passo', clear: ['campo'] }` | Delete the listed fields from `session.data`, then jump. The way to ask something again. |
160
+ | `{ step: 'passo', clear: ['campo'] }` | Delete the listed fields from `session.data` and forget how many times the run asked them, then jump. The way to ask something again. |
158
161
  | `{ flow: 'outro', input? }` | End this run (reason `'flow'`) and start `outro` in the same turn, one hop deeper. The child gets `input`, or this run's `input` when absent. It holds the floor when this run did, or when no run did: a `mention` flow that chains does not take the message from the run it was routed to. `flow` is a template. |
159
162
 
160
163
  Entering a step counts a visit; the visit is part of every key minted there, so a step visited twice sends twice. A `{ step }` jump to an id that no longer exists ends the run with `code: 'step-gone'`; a `{ flow }` to an unknown flow is skipped with `code: 'flow-gone'`.
@@ -104,6 +104,7 @@ type Trigger<C, D, Cond, E> = { repeat?: Repeat } & ( // default: message/m
104
104
 
105
105
  type Talk<C, D, Cond> = ({ prompt: Template; collect?: (keyof D)[] } | { collect: (keyof D)[]; prompt?: Template }) & {
106
106
  ask?: Partial<Record<keyof D, string>>; // per-flow wording; schema `ask` is the default
107
+ question?: Template; // fixed first ask, verbatim, no call; needs collect
107
108
  maxAsks?: number; branches?: Branch<C, D, Cond>[]; tools?: string[]; instructions?: Instruction<C, D>[];
108
109
  };
109
110
 
@@ -121,6 +122,7 @@ interface Flow<C, D, Cond, A extends ActionMap, E> {
121
122
  on?: Trigger<C, D, Cond, E>[]; // absent or [] = Início manual
122
123
  anchor?: string; // 'session' (default) or a host anchor name — "vale por conversa / por lead"
123
124
  while?: Pred<C, D, Cond>; // re-checked whenever the run moves; default = trigger `if`
125
+ collect?: (keyof D)[]; // the data this flow needs; steps' collect orders the asks
124
126
  clearOnStart?: (keyof D)[];
125
127
  steps: Step<C, D, Cond, A, E>[]; // ids required, unique, never 'end'
126
128
  onEnd?: 'end' | 'stay' | 'reset'; // default 'end'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@falai/agent",
3
3
  "packageManager": "bun@1.4.2",
4
- "version": "4.0.0-alpha.12",
4
+ "version": "4.0.0-alpha.13",
5
5
  "description": "Conversational state engine for TypeScript where the AI understands, but the code is in control",
6
6
  "type": "module",
7
7
  "main": "./dist/cjs/index.js",
@@ -44,7 +44,7 @@ import type {
44
44
  import type { StructuredSchema } from "../types/schema.js";
45
45
  import { isDuration } from "../utils/duration.js";
46
46
  import { splitPhrases } from "../utils/phrases.js";
47
- import { toWireSchema } from "../utils/schema.js";
47
+ import { extractMode, toWireSchema } from "../utils/schema.js";
48
48
 
49
49
  // ── The JSON form ───────────────────────────────────────────────────────
50
50
 
@@ -77,7 +77,7 @@ interface TalkSpecExtras {
77
77
  export type StepSpec = StepBase<LooseData> &
78
78
  (
79
79
  | ({ kind: "prompt"; prompt: Template; collect?: undefined } & TalkSpecExtras)
80
- | ({ kind: "collect"; collect: string[]; prompt?: Template } & TalkSpecExtras)
80
+ | ({ kind: "collect"; collect: string[]; prompt?: Template; question?: Template } & TalkSpecExtras)
81
81
  | ({ kind: "say" } & SayStep)
82
82
  | ({ kind: "do" } & DoStep<LooseData>)
83
83
  | { kind: "wait"; wait: Duration; businessHours?: boolean; else?: Next<LooseData>; branches?: BranchSpec[] }
@@ -92,6 +92,7 @@ export interface FlowSpec {
92
92
  on?: TriggerSpec[];
93
93
  anchor?: string;
94
94
  while?: ConditionSpec<LooseData>;
95
+ collect?: string[];
95
96
  clearOnStart?: string[];
96
97
  steps: StepSpec[];
97
98
  onEnd?: "end" | "stay" | "reset";
@@ -136,6 +137,7 @@ export function toSpec<C, D extends LooseData>(flow: Flow<C, D>): FlowSpec {
136
137
  on: flow.on?.map((trigger, i) => triggerToSpec(trigger, at(`trigger #${i + 1}`))),
137
138
  anchor: flow.anchor,
138
139
  while: jsonPred(flow.while, at("while")),
140
+ collect: flow.collect,
139
141
  clearOnStart: flow.clearOnStart,
140
142
  steps: flow.steps.map((step) => stepToSpec(step, at(`step "${step.id}"`))),
141
143
  onEnd: flow.onEnd,
@@ -189,11 +191,12 @@ function stepToSpec<C, D extends LooseData>(step: Step<C, D>, at: string): StepS
189
191
  instructions: step.instructions?.map((ins, i) => instructionToSpec(ins, `${at} instructions[${i}]`)),
190
192
  };
191
193
  if (step.collect !== undefined) {
192
- return compact<StepSpec>({ ...base, kind: "collect", collect: step.collect, prompt: step.prompt, ...talk });
194
+ return compact<StepSpec>({ ...base, kind: "collect", collect: step.collect, prompt: step.prompt, question: step.question, ...talk });
193
195
  }
194
196
  if (step.prompt === undefined) {
195
197
  throw problem(at, "has neither prompt nor collect", "A talk step needs a guideline, fields to collect, or both.");
196
198
  }
199
+ if (step.question !== undefined) throw questionWithoutCollect(at);
197
200
  return compact<StepSpec>({ ...base, kind: "prompt", prompt: step.prompt, ...talk });
198
201
  }
199
202
 
@@ -248,6 +251,7 @@ interface LooseStep {
248
251
  onFail?: Next<LooseData>;
249
252
  prompt?: Template;
250
253
  collect?: string[];
254
+ question?: Template;
251
255
  ask?: Partial<Record<string, string>>;
252
256
  branches?: LooseBranch[];
253
257
  instructions?: LooseInstruction[];
@@ -262,6 +266,7 @@ interface LooseFlow {
262
266
  id: string;
263
267
  on?: LooseTrigger[];
264
268
  while?: LoosePred;
269
+ collect?: string[];
265
270
  clearOnStart?: string[];
266
271
  steps: LooseStep[];
267
272
  instructions?: LooseInstruction[];
@@ -421,7 +426,18 @@ export function validateFlow<C = unknown, D = LooseData>(
421
426
  }
422
427
  };
423
428
 
429
+ for (const field of flow.collect ?? []) slug(field, flowAt, "collect");
424
430
  for (const field of flow.clearOnStart ?? []) slug(field, flowAt, "clearOnStart");
431
+ // A field taken only from the answer to a step that asks it can never be filled when no step asks it.
432
+ const asked = new Set(flow.steps.flatMap((step) => step.collect ?? []));
433
+ for (const field of flow.collect ?? []) {
434
+ if (!asked.has(field) && extractMode(fields[field]) === "asked") {
435
+ warnings.push(
436
+ `${flowAt}: collect lists "${field}", which is only taken from the answer to a step that asks it, and no ` +
437
+ "step does. Add it to a step's collect, or set extract: 'anywhere' on the field.",
438
+ );
439
+ }
440
+ }
425
441
  pred(flow.while, flowAt, "while");
426
442
  toolNames(flow.tools, flowAt);
427
443
  flow.instructions?.forEach((ins, i) => pred(ins.if, flowAt, `instructions[${i}].if`));
@@ -483,8 +499,14 @@ export function validateFlow<C = unknown, D = LooseData>(
483
499
  if (step.collect !== undefined || step.prompt !== undefined) {
484
500
  for (const field of step.collect ?? []) slug(field, at, "collect");
485
501
  for (const field of Object.keys(step.ask ?? {})) slug(field, at, "ask");
502
+ if (step.question !== undefined && !step.collect?.length) throw questionWithoutCollect(at);
486
503
  const fields_ = step.collect ?? [];
487
- if (step.prompt === undefined && fields_.length > 0 && fields_.every((f) => !step.ask?.[f] && !fields[f].ask)) {
504
+ if (
505
+ step.prompt === undefined &&
506
+ step.question === undefined &&
507
+ fields_.length > 0 &&
508
+ fields_.every((f) => !step.ask?.[f] && !fields[f].ask)
509
+ ) {
488
510
  warnings.push(
489
511
  `${at}: collects ${fields_.map((f) => `"${f}"`).join(", ")} with no prompt and no ask; the AI has nothing ` +
490
512
  "to go on. Add a prompt or an ask per field.",
@@ -655,6 +677,7 @@ export function flowSpecSchema(registries: Registries): StructuredSchema {
655
677
  kind: enumOf(["collect"]),
656
678
  collect: { ...slugList, description: "Fields the AI asks for until they are known" },
657
679
  prompt: orNull(STRING),
680
+ question: orNull({ type: "string", description: "A fixed first question, sent word for word; later asks are the AI's" }),
658
681
  maxAsks: orNull({ ...INTEGER, description: "Times a field may be asked before it is skipped; default 3" }),
659
682
  branches,
660
683
  }),
@@ -700,6 +723,7 @@ export function flowSpecSchema(registries: Registries): StructuredSchema {
700
723
  on: orNull(list(trigger, "What starts a run; null = started by hand")),
701
724
  anchor: orNull({ type: "string", description: "'session' (default) or a host anchor such as 'lead'" }),
702
725
  while: orNull({ ...condition, description: "The run ends when this stops holding" }),
726
+ collect: slugList && orNull({ ...slugList, description: "The data this flow needs; its steps ask for it in order, and any of it the lead gives is noted" }),
703
727
  clearOnStart: slugList && orNull({ ...slugList, description: "Fields to forget when a run starts" }),
704
728
  steps: list(step, "In order; a run moves to the next step unless `then` says otherwise"),
705
729
  onEnd: orNull(enumOf(["end", "stay", "reset"], "After the last step: end the run, stay on the last talk step it took answering every message, or reset to the first")),
@@ -751,6 +775,10 @@ function orNull(schema: StructuredSchema): StructuredSchema {
751
775
 
752
776
  // ── Shared helpers ──────────────────────────────────────────────────────
753
777
 
778
+ function questionWithoutCollect(at: string): FlowConfigurationError {
779
+ return problem(at, "has a question but collects nothing", "A fixed question asks for fields: add collect, or send the text with a say step.");
780
+ }
781
+
754
782
  function problem(at: string, what: string, fix: string): FlowConfigurationError {
755
783
  return new FlowConfigurationError(`[FlowConfigurationError] ${at}: ${what}. ${fix}`);
756
784
  }
@@ -137,6 +137,13 @@ function talkKind(step: { collect?: readonly string[] }): StepOutcomeKind {
137
137
  return step.collect?.length ? "collect" : "prompt";
138
138
  }
139
139
 
140
+ /** Everything a flow collects: its own `collect`, then each talk step's, once each. */
141
+ function flowFields<C, D>(flow: Flow<C, D>): string[] {
142
+ const fields = new Set<string>(flow.collect);
143
+ for (const step of flow.steps) if (isTalk(step)) for (const field of step.collect ?? []) fields.add(field);
144
+ return [...fields];
145
+ }
146
+
140
147
  function kindOf<C, D>(step: Step<C, D> | undefined): StepOutcomeKind {
141
148
  if (!step) return "if";
142
149
  if (isTalk(step)) return talkKind(step);
@@ -543,16 +550,25 @@ export class Runner<C = unknown, D = unknown> {
543
550
  }
544
551
  const fields: UnderstandRequest<C, D>["fields"] = {};
545
552
  const data: Record<string, unknown> = turn.session.data;
553
+ const harvestable = (field: string): boolean => {
554
+ const def = this.options.fields[field];
555
+ return def !== undefined && !isKnown(data[field]) && extractMode(def) === "anywhere";
556
+ };
546
557
  for (const flow of [...(floorFlow ? [floorFlow] : []), ...eligible]) {
547
- for (const step of flow.steps) {
548
- if (!isTalk(step)) continue;
549
- for (const field of step.collect ?? []) {
550
- const def = this.options.fields[field];
551
- if (def && !isKnown(data[field]) && extractMode(def) === "anywhere") fields[field] = def;
552
- }
553
- }
558
+ for (const field of flowFields(flow).filter(harvestable)) fields[field] = this.options.fields[field];
559
+ }
560
+ const judging = messageFlows.length > 0 || mentionFlows.length > 0 || branches.length > 0 || Object.keys(fields).length > 0;
561
+ // With nobody on the floor the catch-all may take this message, and an opening message often says the most. The
562
+ // fields its first step asks ride on that step's speak call, so alone they spend no call; a fixed question has no speak call.
563
+ // ponytail: "first step" is steps[0]; a catch-all that opens with a say or an if pays the call for that step's fields too.
564
+ const fallback = floorRun ? undefined : this.messageFlows(turn, (list) => list.length === 0)[0];
565
+ if (fallback) {
566
+ const first = fallback.steps[0];
567
+ const free = new Set<string>(first && isTalk(first) && first.question === undefined ? first.collect : []);
568
+ const own = flowFields(fallback).filter(harvestable);
569
+ if (judging || own.some((field) => !free.has(field))) for (const field of own) fields[field] = this.options.fields[field];
554
570
  }
555
- if (!messageFlows.length && !mentionFlows.length && !branches.length && !Object.keys(fields).length) return null;
571
+ if (!judging && !Object.keys(fields).length) return null;
556
572
  return {
557
573
  text: turn.what.text,
558
574
  history: this.historyOf(turn),
@@ -724,7 +740,31 @@ export class Runner<C = unknown, D = unknown> {
724
740
  turn.queue.push(resumed);
725
741
  await this.drain(turn);
726
742
  }
727
- return this.speaker(turn);
743
+ const speaker = this.speaker(turn);
744
+ if (speaker && !("idle" in speaker) && this.askFixed(turn, speaker)) {
745
+ turn.talk = undefined;
746
+ return null;
747
+ }
748
+ return speaker;
749
+ }
750
+
751
+ /**
752
+ * Send the step's fixed question when this is its first ask: every field it collects still unknown, none asked yet,
753
+ * and the run not staying (a staying run answers the lead). The question counts as one ask of each field. False when
754
+ * the AI should phrase the ask instead.
755
+ */
756
+ private askFixed(turn: Turn<C, D>, { run, step, pending }: TalkRequest<C, D>): boolean {
757
+ const { question, collect = [] } = step;
758
+ if (question === undefined || run.staying) return false;
759
+ if (pending.length !== collect.length || pending.some((field) => (run.asked[field] ?? 0) > 0)) return false;
760
+ const key = this.stepKey(run, step);
761
+ turn.messages.push({ text: render(question, this.scope(turn, run)), kind: "verbatim", afterMs: turn.afterMs, key, runId: run.id, stepId: step.id });
762
+ turn.afterMs = 0;
763
+ turn.spokeBy.add(run.id);
764
+ turn.spoke = true;
765
+ for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
766
+ this.outcome(turn, run, { kind: "collect", status: "ok", key, code: "asked-fixed", stepId: step.id });
767
+ return true;
728
768
  }
729
769
 
730
770
  /**
@@ -873,7 +913,9 @@ export class Runner<C = unknown, D = unknown> {
873
913
  }
874
914
  }
875
915
  run.status = "asking";
916
+ // Reached after Speak (settle's drain), a talk step waits for the next message; a fixed question costs no call, so it goes out now, as a say would.
876
917
  if (!turn.speakDone) turn.talk = { run, flow, step, pending };
918
+ else this.askFixed(turn, { run, flow, step, pending });
877
919
  return;
878
920
  }
879
921
 
@@ -1014,7 +1056,11 @@ export class Runner<C = unknown, D = unknown> {
1014
1056
  }
1015
1057
  if ("step" in target) {
1016
1058
  const data: Record<string, unknown> = turn.session.data;
1017
- for (const field of target.clear ?? []) delete data[field];
1059
+ // Forgetting a field means asking for it from scratch: its ask count goes too, so a fixed question goes out again.
1060
+ for (const field of target.clear ?? []) {
1061
+ delete data[field];
1062
+ delete run.asked[field];
1063
+ }
1018
1064
  this.jump(turn, run, flow, target.step);
1019
1065
  return;
1020
1066
  }
package/src/types/flow.ts CHANGED
@@ -45,6 +45,8 @@ export interface ScalarDef<T extends ScalarType = ScalarType> {
45
45
 
46
46
  /** One collectable field, authored once on the agent. */
47
47
  export interface FieldDef<T extends ScalarType = ScalarType> extends ScalarDef<T> {
48
+ /** The name a person reads, in an editor or next to a collected value. The model never sees it. */
49
+ label?: string;
48
50
  /** How the AI should ask for this field when a step collects it. */
49
51
  ask?: string;
50
52
  /**
@@ -185,6 +187,12 @@ export type TalkStep<C = unknown, D = unknown> = (
185
187
  ) & {
186
188
  /** Per-flow wording for a field; the field's own `ask` is the default. */
187
189
  ask?: Partial<Record<keyof D & string, string>>;
190
+ /**
191
+ * A fixed first question, sent word for word with no model call when the step
192
+ * first asks: every field it collects is still unknown and none was asked yet.
193
+ * Any later ask is the AI's own wording. Needs `collect`.
194
+ */
195
+ question?: Template;
188
196
  /** Times a field may be asked before it is skipped. Default 3. */
189
197
  maxAsks?: number;
190
198
  branches?: Branch<C, D>[];
@@ -253,6 +261,12 @@ export interface Flow<C = unknown, D = unknown> {
253
261
  anchor?: string;
254
262
  /** Re-checked whenever the run moves. Default: the trigger's `if`. */
255
263
  while?: Pred<C, D>;
264
+ /**
265
+ * The data this flow needs, as agent field slugs. Its talk steps' `collect`
266
+ * says which of it to ask, and in what order; a field no step asks is still
267
+ * noted whenever the lead gives it while the flow holds the conversation.
268
+ */
269
+ collect?: (keyof D & string)[];
256
270
  clearOnStart?: (keyof D & string)[];
257
271
  steps: Step<C, D>[];
258
272
  /** What the run does after its last step. Default `'end'`. `'stay'`: the last talk step the run took answers every later message. */
@@ -77,6 +77,7 @@ export type StepOutcomeCode =
77
77
  | "silenced"
78
78
  // A step
79
79
  | "already-known"
80
+ | "asked-fixed"
80
81
  | "another-reply"
81
82
  | "already-sent"
82
83
  | "branch"
@@ -28,6 +28,7 @@ export const OUTCOME_MESSAGES = {
28
28
  silenced: "the host cannot speak right now",
29
29
 
30
30
  "already-known": "every field this step collects is known",
31
+ "asked-fixed": "the step's fixed question went out word for word",
31
32
  "another-reply": "another run already answered this turn",
32
33
  "already-sent": "this message was already sent once",
33
34
  branch: "a branch of this step fired",