@falai/agent 4.0.0-alpha.2 → 4.0.0-alpha.20

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 (300) hide show
  1. package/README.md +5 -3
  2. package/dist/cjs/core/Agent.d.ts +8 -1
  3. package/dist/cjs/core/Agent.d.ts.map +1 -1
  4. package/dist/cjs/core/Agent.js +18 -0
  5. package/dist/cjs/core/Agent.js.map +1 -1
  6. package/dist/cjs/core/CompactionEngine.d.ts.map +1 -1
  7. package/dist/cjs/core/CompactionEngine.js +8 -3
  8. package/dist/cjs/core/CompactionEngine.js.map +1 -1
  9. package/dist/cjs/core/FlowSpec.d.ts +21 -2
  10. package/dist/cjs/core/FlowSpec.d.ts.map +1 -1
  11. package/dist/cjs/core/FlowSpec.js +324 -51
  12. package/dist/cjs/core/FlowSpec.js.map +1 -1
  13. package/dist/cjs/core/Migrate.d.ts.map +1 -1
  14. package/dist/cjs/core/Migrate.js +3 -1
  15. package/dist/cjs/core/Migrate.js.map +1 -1
  16. package/dist/cjs/core/Prompt.d.ts +16 -0
  17. package/dist/cjs/core/Prompt.d.ts.map +1 -1
  18. package/dist/cjs/core/Prompt.js +46 -1
  19. package/dist/cjs/core/Prompt.js.map +1 -1
  20. package/dist/cjs/core/Runner.d.ts +37 -4
  21. package/dist/cjs/core/Runner.d.ts.map +1 -1
  22. package/dist/cjs/core/Runner.js +269 -74
  23. package/dist/cjs/core/Runner.js.map +1 -1
  24. package/dist/cjs/core/Speak.d.ts.map +1 -1
  25. package/dist/cjs/core/Speak.js +50 -19
  26. package/dist/cjs/core/Speak.js.map +1 -1
  27. package/dist/cjs/core/Understand.d.ts +4 -3
  28. package/dist/cjs/core/Understand.d.ts.map +1 -1
  29. package/dist/cjs/core/Understand.js +22 -51
  30. package/dist/cjs/core/Understand.js.map +1 -1
  31. package/dist/cjs/core/contracts.d.ts +11 -6
  32. package/dist/cjs/core/contracts.d.ts.map +1 -1
  33. package/dist/cjs/index.d.ts +1 -1
  34. package/dist/cjs/index.d.ts.map +1 -1
  35. package/dist/cjs/persistence/OpenSearchStore.d.ts +2 -1
  36. package/dist/cjs/persistence/OpenSearchStore.d.ts.map +1 -1
  37. package/dist/cjs/persistence/OpenSearchStore.js +2 -2
  38. package/dist/cjs/persistence/OpenSearchStore.js.map +1 -1
  39. package/dist/cjs/persistence/RedisStore.d.ts +1 -1
  40. package/dist/cjs/persistence/RedisStore.d.ts.map +1 -1
  41. package/dist/cjs/providers/AnthropicProvider.d.ts +2 -5
  42. package/dist/cjs/providers/AnthropicProvider.d.ts.map +1 -1
  43. package/dist/cjs/providers/AnthropicProvider.js +3 -4
  44. package/dist/cjs/providers/AnthropicProvider.js.map +1 -1
  45. package/dist/cjs/providers/DeepSeekProvider.d.ts.map +1 -1
  46. package/dist/cjs/providers/DeepSeekProvider.js +3 -4
  47. package/dist/cjs/providers/DeepSeekProvider.js.map +1 -1
  48. package/dist/cjs/providers/FallbackAiProvider.js +1 -1
  49. package/dist/cjs/providers/FallbackAiProvider.js.map +1 -1
  50. package/dist/cjs/providers/GeminiProvider.d.ts +1 -2
  51. package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
  52. package/dist/cjs/providers/GeminiProvider.js +3 -4
  53. package/dist/cjs/providers/GeminiProvider.js.map +1 -1
  54. package/dist/cjs/providers/GenericOpenAICompatibleProvider.js +4 -4
  55. package/dist/cjs/providers/GenericOpenAICompatibleProvider.js.map +1 -1
  56. package/dist/cjs/providers/OpenAIProvider.d.ts.map +1 -1
  57. package/dist/cjs/providers/OpenAIProvider.js +3 -4
  58. package/dist/cjs/providers/OpenAIProvider.js.map +1 -1
  59. package/dist/cjs/providers/OpenRouterProvider.d.ts.map +1 -1
  60. package/dist/cjs/providers/OpenRouterProvider.js +4 -3
  61. package/dist/cjs/providers/OpenRouterProvider.js.map +1 -1
  62. package/dist/cjs/providers/ProviderAdapter.d.ts +15 -10
  63. package/dist/cjs/providers/ProviderAdapter.d.ts.map +1 -1
  64. package/dist/cjs/providers/ProviderAdapter.js +8 -3
  65. package/dist/cjs/providers/ProviderAdapter.js.map +1 -1
  66. package/dist/cjs/providers/ZaiProvider.js +1 -1
  67. package/dist/cjs/providers/ZaiProvider.js.map +1 -1
  68. package/dist/cjs/types/agent.d.ts +19 -4
  69. package/dist/cjs/types/agent.d.ts.map +1 -1
  70. package/dist/cjs/types/ai.d.ts +4 -6
  71. package/dist/cjs/types/ai.d.ts.map +1 -1
  72. package/dist/cjs/types/compaction.d.ts +1 -0
  73. package/dist/cjs/types/compaction.d.ts.map +1 -1
  74. package/dist/cjs/types/errors.d.ts +4 -9
  75. package/dist/cjs/types/errors.d.ts.map +1 -1
  76. package/dist/cjs/types/errors.js +12 -12
  77. package/dist/cjs/types/errors.js.map +1 -1
  78. package/dist/cjs/types/flow.d.ts +15 -1
  79. package/dist/cjs/types/flow.d.ts.map +1 -1
  80. package/dist/cjs/types/history.d.ts +0 -7
  81. package/dist/cjs/types/history.d.ts.map +1 -1
  82. package/dist/cjs/types/index.d.ts +2 -2
  83. package/dist/cjs/types/index.d.ts.map +1 -1
  84. package/dist/cjs/types/session.d.ts +4 -2
  85. package/dist/cjs/types/session.d.ts.map +1 -1
  86. package/dist/cjs/utils/clock.js +1 -1
  87. package/dist/cjs/utils/clock.js.map +1 -1
  88. package/dist/cjs/utils/outcomes.d.ts +1 -0
  89. package/dist/cjs/utils/outcomes.d.ts.map +1 -1
  90. package/dist/cjs/utils/outcomes.js +1 -0
  91. package/dist/cjs/utils/outcomes.js.map +1 -1
  92. package/dist/cjs/utils/phrases.d.ts +25 -0
  93. package/dist/cjs/utils/phrases.d.ts.map +1 -0
  94. package/dist/cjs/utils/phrases.js +38 -0
  95. package/dist/cjs/utils/phrases.js.map +1 -0
  96. package/dist/cjs/utils/schema.d.ts +3 -12
  97. package/dist/cjs/utils/schema.d.ts.map +1 -1
  98. package/dist/cjs/utils/schema.js +3 -43
  99. package/dist/cjs/utils/schema.js.map +1 -1
  100. package/dist/cjs/utils/template.d.ts +8 -0
  101. package/dist/cjs/utils/template.d.ts.map +1 -1
  102. package/dist/cjs/utils/template.js +47 -3
  103. package/dist/cjs/utils/template.js.map +1 -1
  104. package/dist/core/Agent.d.ts +8 -1
  105. package/dist/core/Agent.d.ts.map +1 -1
  106. package/dist/core/Agent.js +19 -1
  107. package/dist/core/Agent.js.map +1 -1
  108. package/dist/core/CompactionEngine.d.ts.map +1 -1
  109. package/dist/core/CompactionEngine.js +8 -3
  110. package/dist/core/CompactionEngine.js.map +1 -1
  111. package/dist/core/FlowSpec.d.ts +21 -2
  112. package/dist/core/FlowSpec.d.ts.map +1 -1
  113. package/dist/core/FlowSpec.js +323 -52
  114. package/dist/core/FlowSpec.js.map +1 -1
  115. package/dist/core/Migrate.d.ts.map +1 -1
  116. package/dist/core/Migrate.js +3 -1
  117. package/dist/core/Migrate.js.map +1 -1
  118. package/dist/core/Prompt.d.ts +16 -0
  119. package/dist/core/Prompt.d.ts.map +1 -1
  120. package/dist/core/Prompt.js +44 -1
  121. package/dist/core/Prompt.js.map +1 -1
  122. package/dist/core/Runner.d.ts +37 -4
  123. package/dist/core/Runner.d.ts.map +1 -1
  124. package/dist/core/Runner.js +270 -75
  125. package/dist/core/Runner.js.map +1 -1
  126. package/dist/core/Speak.d.ts.map +1 -1
  127. package/dist/core/Speak.js +51 -20
  128. package/dist/core/Speak.js.map +1 -1
  129. package/dist/core/Understand.d.ts +4 -3
  130. package/dist/core/Understand.d.ts.map +1 -1
  131. package/dist/core/Understand.js +22 -51
  132. package/dist/core/Understand.js.map +1 -1
  133. package/dist/core/contracts.d.ts +11 -6
  134. package/dist/core/contracts.d.ts.map +1 -1
  135. package/dist/index.d.ts +1 -1
  136. package/dist/index.d.ts.map +1 -1
  137. package/dist/persistence/OpenSearchStore.d.ts +2 -1
  138. package/dist/persistence/OpenSearchStore.d.ts.map +1 -1
  139. package/dist/persistence/OpenSearchStore.js +2 -2
  140. package/dist/persistence/OpenSearchStore.js.map +1 -1
  141. package/dist/persistence/RedisStore.d.ts +1 -1
  142. package/dist/persistence/RedisStore.d.ts.map +1 -1
  143. package/dist/providers/AnthropicProvider.d.ts +2 -5
  144. package/dist/providers/AnthropicProvider.d.ts.map +1 -1
  145. package/dist/providers/AnthropicProvider.js +3 -4
  146. package/dist/providers/AnthropicProvider.js.map +1 -1
  147. package/dist/providers/DeepSeekProvider.d.ts.map +1 -1
  148. package/dist/providers/DeepSeekProvider.js +3 -4
  149. package/dist/providers/DeepSeekProvider.js.map +1 -1
  150. package/dist/providers/FallbackAiProvider.js +1 -1
  151. package/dist/providers/FallbackAiProvider.js.map +1 -1
  152. package/dist/providers/GeminiProvider.d.ts +1 -2
  153. package/dist/providers/GeminiProvider.d.ts.map +1 -1
  154. package/dist/providers/GeminiProvider.js +3 -4
  155. package/dist/providers/GeminiProvider.js.map +1 -1
  156. package/dist/providers/GenericOpenAICompatibleProvider.js +4 -4
  157. package/dist/providers/GenericOpenAICompatibleProvider.js.map +1 -1
  158. package/dist/providers/OpenAIProvider.d.ts.map +1 -1
  159. package/dist/providers/OpenAIProvider.js +3 -4
  160. package/dist/providers/OpenAIProvider.js.map +1 -1
  161. package/dist/providers/OpenRouterProvider.d.ts.map +1 -1
  162. package/dist/providers/OpenRouterProvider.js +4 -3
  163. package/dist/providers/OpenRouterProvider.js.map +1 -1
  164. package/dist/providers/ProviderAdapter.d.ts +15 -10
  165. package/dist/providers/ProviderAdapter.d.ts.map +1 -1
  166. package/dist/providers/ProviderAdapter.js +8 -3
  167. package/dist/providers/ProviderAdapter.js.map +1 -1
  168. package/dist/providers/ZaiProvider.js +1 -1
  169. package/dist/providers/ZaiProvider.js.map +1 -1
  170. package/dist/types/agent.d.ts +19 -4
  171. package/dist/types/agent.d.ts.map +1 -1
  172. package/dist/types/ai.d.ts +4 -6
  173. package/dist/types/ai.d.ts.map +1 -1
  174. package/dist/types/compaction.d.ts +1 -0
  175. package/dist/types/compaction.d.ts.map +1 -1
  176. package/dist/types/errors.d.ts +4 -9
  177. package/dist/types/errors.d.ts.map +1 -1
  178. package/dist/types/errors.js +12 -12
  179. package/dist/types/errors.js.map +1 -1
  180. package/dist/types/flow.d.ts +15 -1
  181. package/dist/types/flow.d.ts.map +1 -1
  182. package/dist/types/history.d.ts +0 -7
  183. package/dist/types/history.d.ts.map +1 -1
  184. package/dist/types/index.d.ts +2 -2
  185. package/dist/types/index.d.ts.map +1 -1
  186. package/dist/types/session.d.ts +4 -2
  187. package/dist/types/session.d.ts.map +1 -1
  188. package/dist/utils/clock.js +1 -1
  189. package/dist/utils/clock.js.map +1 -1
  190. package/dist/utils/outcomes.d.ts +1 -0
  191. package/dist/utils/outcomes.d.ts.map +1 -1
  192. package/dist/utils/outcomes.js +1 -0
  193. package/dist/utils/outcomes.js.map +1 -1
  194. package/dist/utils/phrases.d.ts +25 -0
  195. package/dist/utils/phrases.d.ts.map +1 -0
  196. package/dist/utils/phrases.js +35 -0
  197. package/dist/utils/phrases.js.map +1 -0
  198. package/dist/utils/schema.d.ts +3 -12
  199. package/dist/utils/schema.d.ts.map +1 -1
  200. package/dist/utils/schema.js +3 -42
  201. package/dist/utils/schema.js.map +1 -1
  202. package/dist/utils/template.d.ts +8 -0
  203. package/dist/utils/template.d.ts.map +1 -1
  204. package/dist/utils/template.js +47 -3
  205. package/dist/utils/template.js.map +1 -1
  206. package/docs/concepts/architecture.md +3 -3
  207. package/docs/concepts/collection.md +40 -5
  208. package/docs/concepts/pipeline.md +12 -10
  209. package/docs/concepts/runs-and-waits.md +2 -2
  210. package/docs/guides/actions-and-events.md +2 -2
  211. package/docs/guides/branching.md +4 -2
  212. package/docs/guides/compaction.md +2 -2
  213. package/docs/guides/conditions.md +1 -1
  214. package/docs/guides/error-handling.md +3 -1
  215. package/docs/guides/flow-control.md +4 -2
  216. package/docs/guides/persistence.md +2 -2
  217. package/docs/guides/testing.md +1 -1
  218. package/docs/guides/triggers.md +5 -5
  219. package/docs/migration/v3-to-v4.md +16 -11
  220. package/docs/reference/actions-events-conditions.md +2 -2
  221. package/docs/reference/agent.md +11 -7
  222. package/docs/reference/branches.md +1 -1
  223. package/docs/reference/errors.md +10 -7
  224. package/docs/reference/fields.md +5 -3
  225. package/docs/reference/flow-spec.md +36 -9
  226. package/docs/reference/flow.md +8 -3
  227. package/docs/reference/outcomes.md +3 -2
  228. package/docs/reference/providers.md +2 -0
  229. package/docs/reference/session.md +2 -0
  230. package/docs/reference/step.md +8 -5
  231. package/docs/reference/stores.md +5 -3
  232. package/docs/reference/trigger.md +24 -4
  233. package/docs/rfc/v4-one-flow.md +7 -5
  234. package/docs/start/01-install.md +5 -3
  235. package/docs/start/04-add-tools.md +1 -1
  236. package/docs/start/05-go-to-production.md +18 -1
  237. package/examples/05-branches.ts +1 -1
  238. package/examples/06-triggers-and-waits.ts +4 -3
  239. package/package.json +5 -3
  240. package/src/core/Agent.ts +23 -2
  241. package/src/core/CompactionEngine.ts +10 -3
  242. package/src/core/FlowSpec.ts +352 -60
  243. package/src/core/Migrate.ts +2 -1
  244. package/src/core/Prompt.ts +47 -1
  245. package/src/core/Runner.ts +266 -71
  246. package/src/core/Speak.ts +53 -19
  247. package/src/core/Understand.ts +17 -50
  248. package/src/core/contracts.ts +11 -4
  249. package/src/index.ts +1 -1
  250. package/src/persistence/OpenSearchStore.ts +3 -2
  251. package/src/persistence/RedisStore.ts +1 -1
  252. package/src/providers/AnthropicProvider.ts +4 -9
  253. package/src/providers/DeepSeekProvider.ts +2 -4
  254. package/src/providers/FallbackAiProvider.ts +1 -1
  255. package/src/providers/GeminiProvider.ts +3 -6
  256. package/src/providers/GenericOpenAICompatibleProvider.ts +4 -4
  257. package/src/providers/OpenAIProvider.ts +3 -4
  258. package/src/providers/OpenRouterProvider.ts +4 -2
  259. package/src/providers/ProviderAdapter.ts +18 -13
  260. package/src/providers/ZaiProvider.ts +1 -1
  261. package/src/types/agent.ts +20 -5
  262. package/src/types/ai.ts +4 -6
  263. package/src/types/compaction.ts +1 -0
  264. package/src/types/errors.ts +11 -12
  265. package/src/types/flow.ts +15 -1
  266. package/src/types/history.ts +0 -10
  267. package/src/types/index.ts +1 -1
  268. package/src/types/session.ts +4 -1
  269. package/src/utils/clock.ts +1 -1
  270. package/src/utils/outcomes.ts +1 -0
  271. package/src/utils/phrases.ts +40 -0
  272. package/src/utils/schema.ts +3 -48
  273. package/src/utils/template.ts +46 -3
  274. package/dist/cjs/providers/index.d.ts +0 -26
  275. package/dist/cjs/providers/index.d.ts.map +0 -1
  276. package/dist/cjs/providers/index.js +0 -30
  277. package/dist/cjs/providers/index.js.map +0 -1
  278. package/dist/cjs/utils/clone.d.ts +0 -8
  279. package/dist/cjs/utils/clone.d.ts.map +0 -1
  280. package/dist/cjs/utils/clone.js +0 -32
  281. package/dist/cjs/utils/clone.js.map +0 -1
  282. package/dist/cjs/utils/index.d.ts +0 -9
  283. package/dist/cjs/utils/index.d.ts.map +0 -1
  284. package/dist/cjs/utils/index.js +0 -29
  285. package/dist/cjs/utils/index.js.map +0 -1
  286. package/dist/providers/index.d.ts +0 -26
  287. package/dist/providers/index.d.ts.map +0 -1
  288. package/dist/providers/index.js +0 -17
  289. package/dist/providers/index.js.map +0 -1
  290. package/dist/utils/clone.d.ts +0 -8
  291. package/dist/utils/clone.d.ts.map +0 -1
  292. package/dist/utils/clone.js +0 -29
  293. package/dist/utils/clone.js.map +0 -1
  294. package/dist/utils/index.d.ts +0 -9
  295. package/dist/utils/index.d.ts.map +0 -1
  296. package/dist/utils/index.js +0 -9
  297. package/dist/utils/index.js.map +0 -1
  298. package/src/providers/index.ts +0 -38
  299. package/src/utils/clone.ts +0 -34
  300. package/src/utils/index.ts +0 -18
@@ -17,7 +17,8 @@
17
17
  */
18
18
  import { FlowConfigurationError } from "../types/errors.js";
19
19
  import { isDuration } from "../utils/duration.js";
20
- import { toWireSchema } from "../utils/schema.js";
20
+ import { splitPhrases } from "../utils/phrases.js";
21
+ import { extractMode, toWireSchema } from "../utils/schema.js";
21
22
  // ── fromSpec / toSpec ───────────────────────────────────────────────────
22
23
  /**
23
24
  * A spec as a typed flow. The slugs and names inside a spec are strings from
@@ -27,9 +28,7 @@ import { toWireSchema } from "../utils/schema.js";
27
28
  */
28
29
  export function fromSpec(spec) {
29
30
  const clean = stripNulls(spec);
30
- if (!Array.isArray(clean.steps)) {
31
- throw problem(`flow "${clean.id}"`, "has no steps list", "Write steps as a list, even an empty one.");
32
- }
31
+ checkShape(clean);
33
32
  const { steps, ...rest } = clean;
34
33
  const flow = { ...rest, steps: steps.map(fromStepSpec) };
35
34
  return flow;
@@ -49,6 +48,7 @@ export function toSpec(flow) {
49
48
  on: flow.on?.map((trigger, i) => triggerToSpec(trigger, at(`trigger #${i + 1}`))),
50
49
  anchor: flow.anchor,
51
50
  while: jsonPred(flow.while, at("while")),
51
+ collect: flow.collect,
52
52
  clearOnStart: flow.clearOnStart,
53
53
  steps: flow.steps.map((step) => stepToSpec(step, at(`step "${step.id}"`))),
54
54
  onEnd: flow.onEnd,
@@ -96,11 +96,13 @@ function stepToSpec(step, at) {
96
96
  instructions: step.instructions?.map((ins, i) => instructionToSpec(ins, `${at} instructions[${i}]`)),
97
97
  };
98
98
  if (step.collect !== undefined) {
99
- return compact({ ...base, kind: "collect", collect: step.collect, prompt: step.prompt, ...talk });
99
+ return compact({ ...base, kind: "collect", collect: step.collect, prompt: step.prompt, question: step.question, ...talk });
100
100
  }
101
101
  if (step.prompt === undefined) {
102
102
  throw problem(at, "has neither prompt nor collect", "A talk step needs a guideline, fields to collect, or both.");
103
103
  }
104
+ if (step.question !== undefined)
105
+ throw questionWithoutCollect(at);
104
106
  return compact({ ...base, kind: "prompt", prompt: step.prompt, ...talk });
105
107
  }
106
108
  /** `typeof step.wait` alone does not narrow the union for tsc; this does. */
@@ -116,8 +118,12 @@ function jsonPredRequired(pred, at) {
116
118
  }
117
119
  return pred;
118
120
  }
119
- const BUILT_IN_CONDITIONS = ["equals", "known", "silenced"];
121
+ export const BUILT_IN_CONDITIONS = ["equals", "known", "silenced"];
120
122
  const DURATION_HINT = 'Write a number and a unit: "30s", "5m", "24h" or "3d".';
123
+ /** The four keys one of which makes an `on[]` entry a trigger. */
124
+ const TRIGGER_KINDS = ["message", "mention", "silence", "event"];
125
+ const ON_END = ["end", "stay", "reset"];
126
+ const INSTRUCTION_KINDS = ["must", "never", "should"];
121
127
  /**
122
128
  * Check a flow, typed or as a spec, against the agent's registries. Throws
123
129
  * `FlowConfigurationError` on the first problem that would break at runtime;
@@ -126,16 +132,14 @@ const DURATION_HINT = 'Write a number and a unit: "30s", "5m", "24h" or "3d".';
126
132
  export function validateFlow(input, registries) {
127
133
  // Nulls mean "not set" in a spec; a typed flow has none, so one pass serves both forms.
128
134
  const flow = stripNulls(input);
129
- const { fields, actions = {}, events = {}, conditions = {}, tools = [] } = registries;
135
+ checkShape(flow);
136
+ const { fields, actions = {}, events = {}, tools = [] } = registries;
130
137
  const toolIds = new Set(tools.map((tool) => tool.id));
131
138
  const warnings = [];
132
139
  if (typeof flow.id !== "string" || flow.id === "") {
133
140
  throw problem("flow", "has no id", "Give the flow a short unique id.");
134
141
  }
135
142
  const flowAt = `flow "${flow.id}"`;
136
- if (!Array.isArray(flow.steps)) {
137
- throw problem(flowAt, "has no steps list", "Write steps as a list, even an empty one.");
138
- }
139
143
  const index = new Map();
140
144
  flow.steps.forEach((step, i) => {
141
145
  if (typeof step.id !== "string" || step.id === "") {
@@ -152,48 +156,14 @@ export function validateFlow(input, registries) {
152
156
  if (flow.on?.length && flow.steps.length === 0) {
153
157
  throw problem(flowAt, "has triggers but no steps", "Add at least one step or remove `on`.");
154
158
  }
155
- const slug = (name, at, where) => {
156
- if (!own(fields, name)) {
157
- throw problem(at, `unknown field "${name}" in ${where}`, "Add it to the agent's fields or fix the slug.");
158
- }
159
- };
159
+ const slug = (name, at, where) => checkSlug(fields, name, at, where);
160
160
  const toolNames = (names, at) => {
161
161
  for (const name of names ?? []) {
162
162
  if (!toolIds.has(name))
163
163
  throw problem(at, `unknown tool "${name}"`, "Register it in the agent's tools or fix the name.");
164
164
  }
165
165
  };
166
- const pred = (value, at, where) => {
167
- if (value === undefined || typeof value === "function")
168
- return;
169
- for (const [name, arg] of Object.entries(value)) {
170
- if (name === "equals") {
171
- if (arg === null || typeof arg !== "object" || Array.isArray(arg)) {
172
- throw problem(at, `${where}.equals is not an object`, "Write equals as { field: value }.");
173
- }
174
- for (const [field, given] of Object.entries(arg)) {
175
- slug(field, at, `${where}.equals`);
176
- const def = fields[field];
177
- if (!matches(def, given)) {
178
- throw problem(at, `${where}.equals gives "${field}" a ${describe(given)}, but the field is a ${def.type}`, `Write a ${def.type}; values are not coerced.`);
179
- }
180
- }
181
- }
182
- else if (name === "known") {
183
- if (!Array.isArray(arg))
184
- throw problem(at, `${where}.known is not a list`, "Write known as [field, ...].");
185
- for (const field of arg)
186
- slug(String(field), at, `${where}.known`);
187
- }
188
- else if (name === "silenced") {
189
- if (typeof arg !== "boolean")
190
- throw problem(at, `${where}.silenced is not a boolean`, "Write true or false.");
191
- }
192
- else if (!own(conditions, name)) {
193
- throw problem(at, `unknown condition "${name}" in ${where}`, `Register it in conditions or use ${BUILT_IN_CONDITIONS.join(", ")}.`);
194
- }
195
- }
196
- };
166
+ const pred = (value, at, where) => checkPred(value, at, where, registries);
197
167
  const duration = (value, at, where) => {
198
168
  if (value !== undefined && !isDuration(value)) {
199
169
  throw problem(at, `${where} has duration "${value}", which does not parse`, DURATION_HINT);
@@ -210,8 +180,16 @@ export function validateFlow(input, registries) {
210
180
  }
211
181
  return to;
212
182
  }
213
- if ("flow" in next)
183
+ if ("flow" in next) {
184
+ // A templated id resolves per run; only a literal one can be checked now. A warning, not a throw:
185
+ // a host that drops one bad row keeps the rest of its agent, and the Runner skips this move as flow-gone.
186
+ const known = registries.flows?.map((f) => f.id);
187
+ if (known && !next.flow.includes("{{") && !known.includes(next.flow)) {
188
+ const fix = known.length ? `Use one of ${known.map((id) => `"${id}"`).join(", ")}, or add the flow.` : "Add the flow to the agent.";
189
+ warnings.push(`${at}: ${where} names flow "${next.flow}", which this agent does not have; a run skips this move with flow-gone. ${fix}`);
190
+ }
214
191
  return undefined;
192
+ }
215
193
  const to = index.get(next.step);
216
194
  if (to === undefined) {
217
195
  throw problem(at, `${where} points at step "${next.step}", which does not exist`, 'Use an existing step id or "end".');
@@ -241,7 +219,8 @@ export function validateFlow(input, registries) {
241
219
  continue;
242
220
  }
243
221
  if (!matchesParam(def, value)) {
244
- throw problem(at, `parameter "${param}" of action "${name}" must be ${describeDef(def)}, got ${describe(value)}`, "Values are not coerced; write the right type.");
222
+ const { expected, got, fix } = mismatch(def, value);
223
+ throw problem(at, `parameter "${param}" of action "${name}" must be ${expected}, got ${got}`, fix);
245
224
  }
246
225
  }
247
226
  for (const param of Object.keys(given)) {
@@ -250,13 +229,31 @@ export function validateFlow(input, registries) {
250
229
  }
251
230
  }
252
231
  };
232
+ for (const field of flow.collect ?? [])
233
+ slug(field, flowAt, "collect");
253
234
  for (const field of flow.clearOnStart ?? [])
254
235
  slug(field, flowAt, "clearOnStart");
236
+ // A field taken only from the answer to a step that asks it can never be filled when no step asks it.
237
+ const asked = new Set(flow.steps.flatMap((step) => step.collect ?? []));
238
+ for (const field of flow.collect ?? []) {
239
+ if (!asked.has(field) && extractMode(fields[field]) === "asked") {
240
+ warnings.push(`${flowAt}: collect lists "${field}", which is only taken from the answer to a step that asks it, and no ` +
241
+ "step does. Add it to a step's collect, or set extract: 'anywhere' on the field.");
242
+ }
243
+ }
255
244
  pred(flow.while, flowAt, "while");
256
245
  toolNames(flow.tools, flowAt);
257
246
  flow.instructions?.forEach((ins, i) => pred(ins.if, flowAt, `instructions[${i}].if`));
258
247
  flow.on?.forEach((trigger, i) => {
259
248
  const at = `${flowAt}, trigger #${i + 1}`;
249
+ // A trigger that names no kind can never fire, and nothing downstream says
250
+ // so: the Runner simply never finds it eligible and the flow looks broken
251
+ // for some other reason. `{ kind: 'message', when: [...] }` — the v3 shape —
252
+ // lands here, and so does a typo in the one key that matters.
253
+ if (!TRIGGER_KINDS.some((key) => trigger[key] !== undefined)) {
254
+ throw problem(at, "names no trigger kind", `A trigger is one of ${TRIGGER_KINDS.map((key) => `\`${key}\``).join(", ")}. ` +
255
+ "A flow the host starts itself has no `on` at all.");
256
+ }
260
257
  if (trigger.event !== undefined && !own(events, trigger.event)) {
261
258
  throw problem(at, `unknown event "${trigger.event}"`, "Register it in events or fix the name.");
262
259
  }
@@ -265,6 +262,17 @@ export function validateFlow(input, registries) {
265
262
  if (typeof trigger.repeat === "object")
266
263
  duration(trigger.repeat.cooldown, at, "repeat.cooldown");
267
264
  pred(trigger.if, at, "if");
265
+ // Phrases opening with `!` rule the trigger out; a list of nothing but
266
+ // those can never fire, so the flow is dead and nothing would say so.
267
+ // `message: []` is the deliberate catch-all and stays legal.
268
+ for (const key of ["message", "mention"]) {
269
+ const phrases = trigger[key];
270
+ if (!phrases?.length)
271
+ continue;
272
+ if (splitPhrases(phrases).counts.length > 0)
273
+ continue;
274
+ throw problem(at, `every ${key} phrase starts with "!", so nothing can ever match it`, `A "!" phrase rules the trigger out. Add at least one plain phrase saying when it should fire${key === "message" ? ", or use an empty list for a catch-all" : ""}.`);
275
+ }
268
276
  });
269
277
  flow.steps.forEach((step, i) => {
270
278
  const at = `${flowAt}, step "${step.id}"`;
@@ -275,8 +283,13 @@ export function validateFlow(input, registries) {
275
283
  slug(field, at, "collect");
276
284
  for (const field of Object.keys(step.ask ?? {}))
277
285
  slug(field, at, "ask");
286
+ if (step.question !== undefined && !step.collect?.length)
287
+ throw questionWithoutCollect(at);
278
288
  const fields_ = step.collect ?? [];
279
- if (step.prompt === undefined && fields_.length > 0 && fields_.every((f) => !step.ask?.[f] && !fields[f].ask)) {
289
+ if (step.prompt === undefined &&
290
+ step.question === undefined &&
291
+ fields_.length > 0 &&
292
+ fields_.every((f) => !step.ask?.[f] && !fields[f].ask)) {
280
293
  warnings.push(`${at}: collects ${fields_.map((f) => `"${f}"`).join(", ")} with no prompt and no ask; the AI has nothing ` +
281
294
  "to go on. Add a prompt or an ask per field.");
282
295
  }
@@ -286,6 +299,12 @@ export function validateFlow(input, registries) {
286
299
  if (branch.when === undefined && branch.if === undefined) {
287
300
  throw problem(at, `${where} has neither when nor if`, "Give the branch an AI condition (when) or a code one (if).");
288
301
  }
302
+ // No understand call judges a wait, so an AI condition there never fires. A warning, not a throw: the
303
+ // flow still runs as it did, a reply goes to `else`, and editors that offered the branch keep their rows.
304
+ if (branch.when !== undefined && step.wait !== undefined) {
305
+ warnings.push(`${at}: ${where} is a "when" branch on a wait step, which no call judges, so a reply goes to else. ` +
306
+ 'Use "if", or move the branch to a talk step.');
307
+ }
289
308
  pred(branch.if, at, `${where}.if`);
290
309
  edge(i, branch.then, at, `${where}.then`);
291
310
  });
@@ -316,6 +335,232 @@ export function validateFlow(input, registries) {
316
335
  });
317
336
  return { warnings };
318
337
  }
338
+ function checkSlug(fields, name, at, where) {
339
+ if (!own(fields, name)) {
340
+ throw problem(at, `unknown field "${name}" in ${where}`, "Add it to the agent's fields or fix the slug.");
341
+ }
342
+ }
343
+ /**
344
+ * A predicate's names against the registries: the fields `equals` and `known`
345
+ * read, and every other key as one of the agent's conditions. A function is
346
+ * code and passes as is. The agent runs this on its own instructions too.
347
+ */
348
+ export function checkPred(value, at, where, registries) {
349
+ if (value === undefined || typeof value === "function")
350
+ return;
351
+ const { fields, conditions = {} } = registries;
352
+ for (const [name, arg] of Object.entries(value)) {
353
+ if (name === "equals") {
354
+ if (!isObject(arg)) {
355
+ throw problem(at, `${where}.equals is not an object`, "Write equals as { field: value }.");
356
+ }
357
+ for (const [field, given] of Object.entries(arg)) {
358
+ checkSlug(fields, field, at, `${where}.equals`);
359
+ const def = fields[field];
360
+ if (!matches(def, given)) {
361
+ const { listed, expected, got, fix } = mismatch(def, given);
362
+ throw listed
363
+ ? problem(at, `${where}.equals gives "${field}" ${got}, which is not ${expected}`, fix)
364
+ : problem(at, `${where}.equals gives "${field}" ${article(got)} ${got}, but the field is ${expected}`, `Write ${expected}; values are not coerced.`);
365
+ }
366
+ }
367
+ }
368
+ else if (name === "known") {
369
+ if (!Array.isArray(arg))
370
+ throw problem(at, `${where}.known is not a list`, "Write known as [field, ...].");
371
+ for (const field of arg)
372
+ checkSlug(fields, String(field), at, `${where}.known`);
373
+ }
374
+ else if (name === "silenced") {
375
+ if (typeof arg !== "boolean")
376
+ throw problem(at, `${where}.silenced is not a boolean`, "Write true or false.");
377
+ }
378
+ else if (!own(conditions, name)) {
379
+ throw problem(at, `unknown condition "${name}" in ${where}`, `Register it in conditions or use ${BUILT_IN_CONDITIONS.join(", ")}.`);
380
+ }
381
+ }
382
+ }
383
+ // ── Shape ───────────────────────────────────────────────────────────────
384
+ /**
385
+ * The JSON shape, checked before any name is. Stored rows and generated specs
386
+ * are untrusted: a string where a list belongs used to crash with a raw
387
+ * TypeError naming no flow, or pass and misbehave at run time (`collect:
388
+ * "nome"` read as the fields "n", "o", "m", "e").
389
+ */
390
+ function checkShape(value) {
391
+ if (!isObject(value))
392
+ throw problem("flow", `is ${show(value)}, not an object`, "Pass the flow itself: { id, name, steps }.");
393
+ const flowAt = typeof value.id === "string" ? `flow "${value.id}"` : "flow";
394
+ if (!Array.isArray(value.steps)) {
395
+ throw problem(flowAt, "has no steps list", "Write steps as a list, even an empty one.");
396
+ }
397
+ text(value, ["description", "anchor"], flowAt);
398
+ for (const key of ["collect", "clearOnStart", "tools"])
399
+ listOf(value[key], key, flowAt, "string");
400
+ oneOf(value.onEnd, "onEnd", ON_END, flowAt);
401
+ predShape(value.while, "while", flowAt);
402
+ instructionsShape(value.instructions, flowAt);
403
+ listOf(value.on, "on", flowAt, "object").forEach((trigger, i) => {
404
+ const at = `${flowAt}, trigger #${i + 1}`;
405
+ if (!isObject(trigger))
406
+ return;
407
+ listOf(trigger.message, "message", at, "string");
408
+ listOf(trigger.mention, "mention", at, "string");
409
+ predShape(trigger.if, "if", at);
410
+ const { repeat } = trigger;
411
+ if (repeat !== undefined && repeat !== "once" && repeat !== "always" && !(isObject(repeat) && typeof repeat.cooldown === "string")) {
412
+ throw problem(at, `repeat is ${show(repeat)}`, 'Use "once", "always" or { cooldown: "24h" }.');
413
+ }
414
+ });
415
+ listOf(value.steps, "steps", flowAt, "object").forEach((step, i) => {
416
+ if (!isObject(step))
417
+ return;
418
+ const at = typeof step.id === "string" ? `${flowAt}, step "${step.id}"` : `${flowAt}, step #${i + 1}`;
419
+ stepShape(step, at);
420
+ });
421
+ }
422
+ /** One step: what it does, and every value the right kind of thing. */
423
+ function stepShape(step, at) {
424
+ // A step that does none of the five things is one the run walks straight
425
+ // past; one that does two runs only the first, and the other never happens.
426
+ const kinds = bodyKinds(step);
427
+ if (kinds.length === 0) {
428
+ throw problem(at, "does nothing", "A step talks (`prompt` / `collect`), says (`say`), acts (`do`), waits (`wait`) or forks (`if`).");
429
+ }
430
+ if (kinds.length > 1) {
431
+ throw problem(at, `mixes ${and(kinds.map((k) => `"${k}"`))}`, "A step does one thing. Split it into one step per kind.");
432
+ }
433
+ // The spec's `kind` must say what the body does; the Runner reads the body.
434
+ if (step.kind !== undefined && step.kind !== kinds[0]) {
435
+ throw problem(at, `has kind ${show(step.kind)}, but its body is a "${kinds[0]}" step`, `Set kind to "${kinds[0]}", or change the body to match.`);
436
+ }
437
+ text(step, ["say", "prompt", "question"], at);
438
+ listOf(step.collect, "collect", at, "string");
439
+ listOf(step.tools, "tools", at, "string");
440
+ if (step.ask !== undefined && !isObject(step.ask)) {
441
+ throw problem(at, `ask is ${show(step.ask)}, not an object`, 'Write ask as { field: "how to ask" }.');
442
+ }
443
+ if (step.with !== undefined && !isObject(step.with)) {
444
+ throw problem(at, `with is ${show(step.with)}, not an object`, "Write with as { parameter: value }.");
445
+ }
446
+ const { maxAsks } = step;
447
+ if (maxAsks !== undefined && !(typeof maxAsks === "number" && Number.isInteger(maxAsks) && maxAsks >= 1)) {
448
+ throw problem(at, `maxAsks is ${show(maxAsks)}, not a whole number of 1 or more`, "Write a number like 3.");
449
+ }
450
+ if (isObject(step.wait) && typeof step.wait.event !== "string") {
451
+ throw problem(at, "wait has no event", 'Write wait: { event: "name" } to wait for an event, or a duration like "1h".');
452
+ }
453
+ predShape(step.if, "if", at);
454
+ for (const key of ["then", "else", "onFail"])
455
+ nextShape(step[key], key, at);
456
+ instructionsShape(step.instructions, at);
457
+ listOf(step.branches, "branches", at, "object").forEach((branch, j) => {
458
+ if (!isObject(branch))
459
+ return;
460
+ const where = `branches[${j}]`;
461
+ if (branch.when !== undefined && typeof branch.when !== "string") {
462
+ throw problem(at, `${where}.when is ${show(branch.when)}, not text`, "Write the condition as one sentence.");
463
+ }
464
+ predShape(branch.if, `${where}.if`, at);
465
+ nextShape(branch.then, `${where}.then`, at);
466
+ });
467
+ }
468
+ /** The kinds a step's body carries, by its keys. A talk step is `collect` with a list, `prompt` with a guideline alone. */
469
+ function bodyKinds(step) {
470
+ const kinds = [];
471
+ if (step.collect !== undefined)
472
+ kinds.push("collect");
473
+ else if (step.prompt !== undefined)
474
+ kinds.push("prompt");
475
+ if (step.say !== undefined)
476
+ kinds.push("say");
477
+ if (step.do !== undefined)
478
+ kinds.push("do");
479
+ if (step.wait !== undefined)
480
+ kinds.push(typeof step.wait === "string" ? "wait" : "waitEvent");
481
+ if (step.if !== undefined)
482
+ kinds.push("if");
483
+ return kinds;
484
+ }
485
+ function instructionsShape(value, at) {
486
+ listOf(value, "instructions", at, "object").forEach((ins, i) => {
487
+ if (!isObject(ins))
488
+ return;
489
+ const where = `instructions[${i}]`;
490
+ oneOf(ins.kind, `${where}.kind`, INSTRUCTION_KINDS, at);
491
+ if (typeof ins.prompt !== "string") {
492
+ throw problem(at, `${where}.prompt is ${show(ins.prompt)}, not text`, "Write the rule as a sentence.");
493
+ }
494
+ if (ins.when !== undefined && typeof ins.when !== "string")
495
+ listOf(ins.when, `${where}.when`, at, "string");
496
+ predShape(ins.if, `${where}.if`, at);
497
+ });
498
+ }
499
+ /** A step id, `"end"`, `{ step, clear? }` or `{ flow, input? }`. */
500
+ function nextShape(value, where, at) {
501
+ if (value === undefined || typeof value === "string")
502
+ return;
503
+ if (isObject(value) && (typeof value.step === "string" || typeof value.flow === "string")) {
504
+ listOf(value.clear, `${where}.clear`, at, "string");
505
+ return;
506
+ }
507
+ throw problem(at, `${where} is ${show(value)}, not a step id or a target`, 'Write a step id, "end", { step: "id" } or { flow: "id" }.');
508
+ }
509
+ /** A condition object, or code on a typed flow. */
510
+ function predShape(value, where, at) {
511
+ if (value === undefined || typeof value === "function" || isObject(value))
512
+ return;
513
+ throw problem(at, `${where} is ${show(value)}, not a condition`, 'Write it as an object, e.g. { known: ["nome"] }.');
514
+ }
515
+ /** Each named key, when set, must be text. */
516
+ function text(owner, keys, at) {
517
+ for (const key of keys) {
518
+ const value = owner[key];
519
+ if (value !== undefined && typeof value !== "string") {
520
+ throw problem(at, `${key} is ${show(value)}, not text`, `Write ${key} as a string.`);
521
+ }
522
+ }
523
+ }
524
+ function oneOf(value, where, allowed, at) {
525
+ if (value === undefined || (typeof value === "string" && allowed.includes(value)))
526
+ return;
527
+ throw problem(at, `${where} is ${show(value)}, which is not one of ${allowed.map((v) => `"${v}"`).join(", ")}`, "Use one of them.");
528
+ }
529
+ /** A list whose items are all strings or all objects; absent reads as empty. */
530
+ function listOf(value, where, at, item) {
531
+ if (value === undefined)
532
+ return [];
533
+ if (!Array.isArray(value)) {
534
+ const example = item === "string" && typeof value === "string" ? `${where}: ${JSON.stringify([value])}` : `${where} as a list`;
535
+ throw problem(at, `${where} is ${show(value)}, not a list`, `Write ${example}.`);
536
+ }
537
+ value.forEach((entry, i) => {
538
+ if (item === "string" ? typeof entry !== "string" : !isObject(entry)) {
539
+ throw problem(at, `${where}[${i}] is ${show(entry)}, not ${item === "string" ? "text" : "an object"}`, `Write each entry of ${where} as ${item === "string" ? "a string" : "an object"}.`);
540
+ }
541
+ });
542
+ return value;
543
+ }
544
+ /** A plain object: not null, not a list, not a function. */
545
+ function isObject(value) {
546
+ return typeof value === "object" && value !== null && !Array.isArray(value);
547
+ }
548
+ /** A wrong value as an error names it: short scalars verbatim, anything else by kind. */
549
+ function show(value) {
550
+ if (typeof value === "string")
551
+ return JSON.stringify(value.length > 40 ? `${value.slice(0, 37)}...` : value);
552
+ if (typeof value === "number" || typeof value === "boolean")
553
+ return String(value);
554
+ if (value === undefined)
555
+ return "missing";
556
+ if (value === null)
557
+ return "null";
558
+ return Array.isArray(value) ? "a list" : typeof value === "function" ? "a function" : "an object";
559
+ }
560
+ /** `"a"`, `"a" and "b"`, `"a", "b" and "c"`. */
561
+ function and(items) {
562
+ return items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
563
+ }
319
564
  function matchesParam(def, value) {
320
565
  if (def.type === "array")
321
566
  return Array.isArray(value) && value.every((item) => matches(def.items, item));
@@ -336,11 +581,30 @@ function matches(def, value) {
336
581
  return true;
337
582
  return typeof value !== "boolean" && def.enum.includes(value);
338
583
  }
584
+ /**
585
+ * What a rejected value should have been and what it was. A value of the right type that is
586
+ * not a listed one names the listed values: "must be a string, got string" would say nothing.
587
+ */
588
+ function mismatch(def, value) {
589
+ const scalar = def.type === "array" ? def.items : def;
590
+ const { enum: allowed, ...typeOnly } = scalar;
591
+ const items = def.type === "array" && Array.isArray(value) ? value : [value];
592
+ const off = allowed ? items.findIndex((item) => matches(typeOnly, item) && !matches(scalar, item)) : -1;
593
+ if (!allowed || off === -1) {
594
+ return { listed: false, expected: describeDef(def), got: describe(value), fix: "Values are not coerced; write the right type." };
595
+ }
596
+ const list = (v) => JSON.stringify(v);
597
+ return { listed: true, expected: `one of ${allowed.map(list).join(", ")}`, got: list(items[off]), fix: "Use one of the listed values." };
598
+ }
339
599
  function describe(value) {
340
600
  return Array.isArray(value) ? "list" : value === null ? "null" : typeof value;
341
601
  }
342
602
  function describeDef(def) {
343
- return def.type === "array" ? `a list of ${def.items.type}` : `a ${def.type}`;
603
+ return def.type === "array" ? `a list of ${def.items.type}s` : `${article(def.type)} ${def.type}`;
604
+ }
605
+ /** "an integer", "a string". A vowel test rather than one hard-coded type, so a new type reads right for free. */
606
+ function article(type) {
607
+ return /^[aeiou]/.test(type) ? "an" : "a";
344
608
  }
345
609
  // ── flowSpecSchema ──────────────────────────────────────────────────────
346
610
  const STRING = { type: "string" };
@@ -398,6 +662,8 @@ export function flowSpecSchema(registries) {
398
662
  : undefined,
399
663
  ]);
400
664
  const branches = orNull(list(union([closed({ when: STRING, then: next }), closed({ if: condition, then: next })]), "Exits judged while the step asks"));
665
+ // No understand call judges a wait, so its branches are code only.
666
+ const waitBranches = orNull(list(closed({ if: condition, then: next }), "Exits checked by code when the lead replies before the wait ends"));
401
667
  const stepBase = { id: STRING, label: orNull(STRING), then: nextOrNull };
402
668
  const step = union([
403
669
  closed({ ...stepBase, kind: enumOf(["prompt"]), prompt: { type: "string", description: "Guideline for the AI's next message" }, branches }),
@@ -407,6 +673,7 @@ export function flowSpecSchema(registries) {
407
673
  kind: enumOf(["collect"]),
408
674
  collect: { ...slugList, description: "Fields the AI asks for until they are known" },
409
675
  prompt: orNull(STRING),
676
+ question: orNull({ type: "string", description: "A fixed first question, sent word for word; later asks are the AI's" }),
410
677
  maxAsks: orNull({ ...INTEGER, description: "Times a field may be asked before it is skipped; default 3" }),
411
678
  branches,
412
679
  }),
@@ -424,7 +691,7 @@ export function flowSpecSchema(registries) {
424
691
  wait: { ...duration, description: "Park this long; then = time passed, else = the lead replied" },
425
692
  businessHours: orNull(BOOLEAN),
426
693
  else: nextOrNull,
427
- branches,
694
+ branches: waitBranches,
428
695
  }),
429
696
  eventNames.length
430
697
  ? closed({
@@ -449,9 +716,10 @@ export function flowSpecSchema(registries) {
449
716
  on: orNull(list(trigger, "What starts a run; null = started by hand")),
450
717
  anchor: orNull({ type: "string", description: "'session' (default) or a host anchor such as 'lead'" }),
451
718
  while: orNull({ ...condition, description: "The run ends when this stops holding" }),
719
+ 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" }),
452
720
  clearOnStart: slugList && orNull({ ...slugList, description: "Fields to forget when a run starts" }),
453
721
  steps: list(step, "In order; a run moves to the next step unless `then` says otherwise"),
454
- onEnd: orNull(enumOf(["end", "stay", "reset"], "After the last step: end the run, stay on it, or reset to the first")),
722
+ 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")),
455
723
  instructions: orNull(list(instruction, "Rules that apply only inside this flow")),
456
724
  });
457
725
  }
@@ -492,6 +760,9 @@ function orNull(schema) {
492
760
  return typeof schema.type === "string" ? { ...schema, type: [schema.type, "null"] } : { anyOf: [schema, NULL] };
493
761
  }
494
762
  // ── Shared helpers ──────────────────────────────────────────────────────
763
+ function questionWithoutCollect(at) {
764
+ 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.");
765
+ }
495
766
  function problem(at, what, fix) {
496
767
  return new FlowConfigurationError(`[FlowConfigurationError] ${at}: ${what}. ${fix}`);
497
768
  }