@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
@@ -43,7 +43,8 @@ import type {
43
43
  } from "../types/flow.js";
44
44
  import type { StructuredSchema } from "../types/schema.js";
45
45
  import { isDuration } from "../utils/duration.js";
46
- import { toWireSchema } from "../utils/schema.js";
46
+ import { splitPhrases } from "../utils/phrases.js";
47
+ import { extractMode, toWireSchema } from "../utils/schema.js";
47
48
 
48
49
  // ── The JSON form ───────────────────────────────────────────────────────
49
50
 
@@ -76,7 +77,7 @@ interface TalkSpecExtras {
76
77
  export type StepSpec = StepBase<LooseData> &
77
78
  (
78
79
  | ({ kind: "prompt"; prompt: Template; collect?: undefined } & TalkSpecExtras)
79
- | ({ kind: "collect"; collect: string[]; prompt?: Template } & TalkSpecExtras)
80
+ | ({ kind: "collect"; collect: string[]; prompt?: Template; question?: Template } & TalkSpecExtras)
80
81
  | ({ kind: "say" } & SayStep)
81
82
  | ({ kind: "do" } & DoStep<LooseData>)
82
83
  | { kind: "wait"; wait: Duration; businessHours?: boolean; else?: Next<LooseData>; branches?: BranchSpec[] }
@@ -91,6 +92,7 @@ export interface FlowSpec {
91
92
  on?: TriggerSpec[];
92
93
  anchor?: string;
93
94
  while?: ConditionSpec<LooseData>;
95
+ collect?: string[];
94
96
  clearOnStart?: string[];
95
97
  steps: StepSpec[];
96
98
  onEnd?: "end" | "stay" | "reset";
@@ -98,8 +100,14 @@ export interface FlowSpec {
98
100
  tools?: string[];
99
101
  }
100
102
 
101
- /** What a flow's names resolve against: the agent's fields, actions, events, conditions and tools. */
102
- export type Registries = Pick<AgentOptions, "fields" | "actions" | "events" | "conditions" | "tools">;
103
+ /**
104
+ * What a flow's names resolve against: the agent's fields, actions, events,
105
+ * conditions and tools. With `flows`, a literal `{ flow }` target must name
106
+ * one of them; the agent passes its own.
107
+ */
108
+ export type Registries = Pick<AgentOptions, "fields" | "actions" | "events" | "conditions" | "tools"> & {
109
+ flows?: ReadonlyArray<{ id: string }>;
110
+ };
103
111
 
104
112
  // ── fromSpec / toSpec ───────────────────────────────────────────────────
105
113
 
@@ -111,9 +119,7 @@ export type Registries = Pick<AgentOptions, "fields" | "actions" | "events" | "c
111
119
  */
112
120
  export function fromSpec<C = unknown, D = LooseData>(spec: FlowSpec): Flow<C, D> {
113
121
  const clean = stripNulls(spec);
114
- if (!Array.isArray(clean.steps)) {
115
- throw problem(`flow "${clean.id}"`, "has no steps list", "Write steps as a list, even an empty one.");
116
- }
122
+ checkShape(clean);
117
123
  const { steps, ...rest } = clean;
118
124
  const flow: Flow<unknown, LooseData> = { ...rest, steps: steps.map(fromStepSpec) };
119
125
  return flow as Flow<C, D>;
@@ -135,6 +141,7 @@ export function toSpec<C, D extends LooseData>(flow: Flow<C, D>): FlowSpec {
135
141
  on: flow.on?.map((trigger, i) => triggerToSpec(trigger, at(`trigger #${i + 1}`))),
136
142
  anchor: flow.anchor,
137
143
  while: jsonPred(flow.while, at("while")),
144
+ collect: flow.collect,
138
145
  clearOnStart: flow.clearOnStart,
139
146
  steps: flow.steps.map((step) => stepToSpec(step, at(`step "${step.id}"`))),
140
147
  onEnd: flow.onEnd,
@@ -188,11 +195,12 @@ function stepToSpec<C, D extends LooseData>(step: Step<C, D>, at: string): StepS
188
195
  instructions: step.instructions?.map((ins, i) => instructionToSpec(ins, `${at} instructions[${i}]`)),
189
196
  };
190
197
  if (step.collect !== undefined) {
191
- return compact<StepSpec>({ ...base, kind: "collect", collect: step.collect, prompt: step.prompt, ...talk });
198
+ return compact<StepSpec>({ ...base, kind: "collect", collect: step.collect, prompt: step.prompt, question: step.question, ...talk });
192
199
  }
193
200
  if (step.prompt === undefined) {
194
201
  throw problem(at, "has neither prompt nor collect", "A talk step needs a guideline, fields to collect, or both.");
195
202
  }
203
+ if (step.question !== undefined) throw questionWithoutCollect(at);
196
204
  return compact<StepSpec>({ ...base, kind: "prompt", prompt: step.prompt, ...talk });
197
205
  }
198
206
 
@@ -233,6 +241,8 @@ interface LooseInstruction {
233
241
  }
234
242
  interface LooseTrigger {
235
243
  repeat?: Repeat;
244
+ message?: string[];
245
+ mention?: string[];
236
246
  event?: string;
237
247
  silence?: Duration;
238
248
  after?: Duration;
@@ -245,9 +255,11 @@ interface LooseStep {
245
255
  onFail?: Next<LooseData>;
246
256
  prompt?: Template;
247
257
  collect?: string[];
258
+ question?: Template;
248
259
  ask?: Partial<Record<string, string>>;
249
260
  branches?: LooseBranch[];
250
261
  instructions?: LooseInstruction[];
262
+ say?: Template;
251
263
  do?: string;
252
264
  with?: Record<string, unknown>;
253
265
  wait?: Duration | { event: string; upTo?: Duration };
@@ -258,16 +270,24 @@ interface LooseFlow {
258
270
  id: string;
259
271
  on?: LooseTrigger[];
260
272
  while?: LoosePred;
273
+ collect?: string[];
261
274
  clearOnStart?: string[];
262
275
  steps: LooseStep[];
263
276
  instructions?: LooseInstruction[];
264
277
  tools?: string[];
265
278
  }
266
279
 
267
- const BUILT_IN_CONDITIONS = ["equals", "known", "silenced"];
280
+ export const BUILT_IN_CONDITIONS = ["equals", "known", "silenced"];
268
281
 
269
282
  const DURATION_HINT = 'Write a number and a unit: "30s", "5m", "24h" or "3d".';
270
283
 
284
+ /** The four keys one of which makes an `on[]` entry a trigger. */
285
+ const TRIGGER_KINDS = ["message", "mention", "silence", "event"] as const;
286
+
287
+ const ON_END = ["end", "stay", "reset"];
288
+
289
+ const INSTRUCTION_KINDS = ["must", "never", "should"];
290
+
271
291
  /**
272
292
  * Check a flow, typed or as a spec, against the agent's registries. Throws
273
293
  * `FlowConfigurationError` on the first problem that would break at runtime;
@@ -279,7 +299,8 @@ export function validateFlow<C = unknown, D = LooseData>(
279
299
  ): { warnings: string[] } {
280
300
  // Nulls mean "not set" in a spec; a typed flow has none, so one pass serves both forms.
281
301
  const flow: LooseFlow = stripNulls(input);
282
- const { fields, actions = {}, events = {}, conditions = {}, tools = [] } = registries;
302
+ checkShape(flow);
303
+ const { fields, actions = {}, events = {}, tools = [] } = registries;
283
304
  const toolIds = new Set(tools.map((tool) => tool.id));
284
305
  const warnings: string[] = [];
285
306
 
@@ -287,9 +308,6 @@ export function validateFlow<C = unknown, D = LooseData>(
287
308
  throw problem("flow", "has no id", "Give the flow a short unique id.");
288
309
  }
289
310
  const flowAt = `flow "${flow.id}"`;
290
- if (!Array.isArray(flow.steps)) {
291
- throw problem(flowAt, "has no steps list", "Write steps as a list, even an empty one.");
292
- }
293
311
 
294
312
  const index = new Map<string, number>();
295
313
  flow.steps.forEach((step, i) => {
@@ -308,11 +326,7 @@ export function validateFlow<C = unknown, D = LooseData>(
308
326
  throw problem(flowAt, "has triggers but no steps", "Add at least one step or remove `on`.");
309
327
  }
310
328
 
311
- const slug = (name: string, at: string, where: string): void => {
312
- if (!own(fields, name)) {
313
- throw problem(at, `unknown field "${name}" in ${where}`, "Add it to the agent's fields or fix the slug.");
314
- }
315
- };
329
+ const slug = (name: string, at: string, where: string): void => checkSlug(fields, name, at, where);
316
330
 
317
331
  const toolNames = (names: string[] | undefined, at: string): void => {
318
332
  for (const name of names ?? []) {
@@ -320,38 +334,7 @@ export function validateFlow<C = unknown, D = LooseData>(
320
334
  }
321
335
  };
322
336
 
323
- const pred = (value: LoosePred | undefined, at: string, where: string): void => {
324
- if (value === undefined || typeof value === "function") return;
325
- for (const [name, arg] of Object.entries(value)) {
326
- if (name === "equals") {
327
- if (arg === null || typeof arg !== "object" || Array.isArray(arg)) {
328
- throw problem(at, `${where}.equals is not an object`, "Write equals as { field: value }.");
329
- }
330
- for (const [field, given] of Object.entries(arg)) {
331
- slug(field, at, `${where}.equals`);
332
- const def = fields[field];
333
- if (!matches(def, given)) {
334
- throw problem(
335
- at,
336
- `${where}.equals gives "${field}" a ${describe(given)}, but the field is a ${def.type}`,
337
- `Write a ${def.type}; values are not coerced.`,
338
- );
339
- }
340
- }
341
- } else if (name === "known") {
342
- if (!Array.isArray(arg)) throw problem(at, `${where}.known is not a list`, "Write known as [field, ...].");
343
- for (const field of arg) slug(String(field), at, `${where}.known`);
344
- } else if (name === "silenced") {
345
- if (typeof arg !== "boolean") throw problem(at, `${where}.silenced is not a boolean`, "Write true or false.");
346
- } else if (!own(conditions, name)) {
347
- throw problem(
348
- at,
349
- `unknown condition "${name}" in ${where}`,
350
- `Register it in conditions or use ${BUILT_IN_CONDITIONS.join(", ")}.`,
351
- );
352
- }
353
- }
354
- };
337
+ const pred = (value: LoosePred | undefined, at: string, where: string): void => checkPred(value, at, where, registries);
355
338
 
356
339
  const duration = (value: string | undefined, at: string, where: string): void => {
357
340
  if (value !== undefined && !isDuration(value)) {
@@ -369,7 +352,16 @@ export function validateFlow<C = unknown, D = LooseData>(
369
352
  }
370
353
  return to;
371
354
  }
372
- if ("flow" in next) return undefined;
355
+ if ("flow" in next) {
356
+ // A templated id resolves per run; only a literal one can be checked now. A warning, not a throw:
357
+ // a host that drops one bad row keeps the rest of its agent, and the Runner skips this move as flow-gone.
358
+ const known = registries.flows?.map((f) => f.id);
359
+ if (known && !next.flow.includes("{{") && !known.includes(next.flow)) {
360
+ const fix = known.length ? `Use one of ${known.map((id) => `"${id}"`).join(", ")}, or add the flow.` : "Add the flow to the agent.";
361
+ warnings.push(`${at}: ${where} names flow "${next.flow}", which this agent does not have; a run skips this move with flow-gone. ${fix}`);
362
+ }
363
+ return undefined;
364
+ }
373
365
  const to = index.get(next.step);
374
366
  if (to === undefined) {
375
367
  throw problem(at, `${where} points at step "${next.step}", which does not exist`, 'Use an existing step id or "end".');
@@ -400,11 +392,8 @@ export function validateFlow<C = unknown, D = LooseData>(
400
392
  continue;
401
393
  }
402
394
  if (!matchesParam(def, value)) {
403
- throw problem(
404
- at,
405
- `parameter "${param}" of action "${name}" must be ${describeDef(def)}, got ${describe(value)}`,
406
- "Values are not coerced; write the right type.",
407
- );
395
+ const { expected, got, fix } = mismatch(def, value);
396
+ throw problem(at, `parameter "${param}" of action "${name}" must be ${expected}, got ${got}`, fix);
408
397
  }
409
398
  }
410
399
  for (const param of Object.keys(given)) {
@@ -414,13 +403,36 @@ export function validateFlow<C = unknown, D = LooseData>(
414
403
  }
415
404
  };
416
405
 
406
+ for (const field of flow.collect ?? []) slug(field, flowAt, "collect");
417
407
  for (const field of flow.clearOnStart ?? []) slug(field, flowAt, "clearOnStart");
408
+ // A field taken only from the answer to a step that asks it can never be filled when no step asks it.
409
+ const asked = new Set(flow.steps.flatMap((step) => step.collect ?? []));
410
+ for (const field of flow.collect ?? []) {
411
+ if (!asked.has(field) && extractMode(fields[field]) === "asked") {
412
+ warnings.push(
413
+ `${flowAt}: collect lists "${field}", which is only taken from the answer to a step that asks it, and no ` +
414
+ "step does. Add it to a step's collect, or set extract: 'anywhere' on the field.",
415
+ );
416
+ }
417
+ }
418
418
  pred(flow.while, flowAt, "while");
419
419
  toolNames(flow.tools, flowAt);
420
420
  flow.instructions?.forEach((ins, i) => pred(ins.if, flowAt, `instructions[${i}].if`));
421
421
 
422
422
  flow.on?.forEach((trigger, i) => {
423
423
  const at = `${flowAt}, trigger #${i + 1}`;
424
+ // A trigger that names no kind can never fire, and nothing downstream says
425
+ // so: the Runner simply never finds it eligible and the flow looks broken
426
+ // for some other reason. `{ kind: 'message', when: [...] }` — the v3 shape —
427
+ // lands here, and so does a typo in the one key that matters.
428
+ if (!TRIGGER_KINDS.some((key) => trigger[key] !== undefined)) {
429
+ throw problem(
430
+ at,
431
+ "names no trigger kind",
432
+ `A trigger is one of ${TRIGGER_KINDS.map((key) => `\`${key}\``).join(", ")}. ` +
433
+ "A flow the host starts itself has no `on` at all.",
434
+ );
435
+ }
424
436
  if (trigger.event !== undefined && !own(events, trigger.event)) {
425
437
  throw problem(at, `unknown event "${trigger.event}"`, "Register it in events or fix the name.");
426
438
  }
@@ -428,6 +440,21 @@ export function validateFlow<C = unknown, D = LooseData>(
428
440
  duration(trigger.after, at, "after");
429
441
  if (typeof trigger.repeat === "object") duration(trigger.repeat.cooldown, at, "repeat.cooldown");
430
442
  pred(trigger.if, at, "if");
443
+ // Phrases opening with `!` rule the trigger out; a list of nothing but
444
+ // those can never fire, so the flow is dead and nothing would say so.
445
+ // `message: []` is the deliberate catch-all and stays legal.
446
+ for (const key of ["message", "mention"] as const) {
447
+ const phrases: string[] | undefined = trigger[key];
448
+ if (!phrases?.length) continue;
449
+ if (splitPhrases(phrases).counts.length > 0) continue;
450
+ throw problem(
451
+ at,
452
+ `every ${key} phrase starts with "!", so nothing can ever match it`,
453
+ `A "!" phrase rules the trigger out. Add at least one plain phrase saying when it should fire${
454
+ key === "message" ? ", or use an empty list for a catch-all" : ""
455
+ }.`,
456
+ );
457
+ }
431
458
  });
432
459
 
433
460
  flow.steps.forEach((step, i) => {
@@ -438,8 +465,14 @@ export function validateFlow<C = unknown, D = LooseData>(
438
465
  if (step.collect !== undefined || step.prompt !== undefined) {
439
466
  for (const field of step.collect ?? []) slug(field, at, "collect");
440
467
  for (const field of Object.keys(step.ask ?? {})) slug(field, at, "ask");
468
+ if (step.question !== undefined && !step.collect?.length) throw questionWithoutCollect(at);
441
469
  const fields_ = step.collect ?? [];
442
- if (step.prompt === undefined && fields_.length > 0 && fields_.every((f) => !step.ask?.[f] && !fields[f].ask)) {
470
+ if (
471
+ step.prompt === undefined &&
472
+ step.question === undefined &&
473
+ fields_.length > 0 &&
474
+ fields_.every((f) => !step.ask?.[f] && !fields[f].ask)
475
+ ) {
443
476
  warnings.push(
444
477
  `${at}: collects ${fields_.map((f) => `"${f}"`).join(", ")} with no prompt and no ask; the AI has nothing ` +
445
478
  "to go on. Add a prompt or an ask per field.",
@@ -451,6 +484,14 @@ export function validateFlow<C = unknown, D = LooseData>(
451
484
  if (branch.when === undefined && branch.if === undefined) {
452
485
  throw problem(at, `${where} has neither when nor if`, "Give the branch an AI condition (when) or a code one (if).");
453
486
  }
487
+ // No understand call judges a wait, so an AI condition there never fires. A warning, not a throw: the
488
+ // flow still runs as it did, a reply goes to `else`, and editors that offered the branch keep their rows.
489
+ if (branch.when !== undefined && step.wait !== undefined) {
490
+ warnings.push(
491
+ `${at}: ${where} is a "when" branch on a wait step, which no call judges, so a reply goes to else. ` +
492
+ 'Use "if", or move the branch to a talk step.',
493
+ );
494
+ }
454
495
  pred(branch.if, at, `${where}.if`);
455
496
  edge(i, branch.then, at, `${where}.then`);
456
497
  });
@@ -487,6 +528,228 @@ export function validateFlow<C = unknown, D = LooseData>(
487
528
  return { warnings };
488
529
  }
489
530
 
531
+ function checkSlug(fields: FieldDefs, name: string, at: string, where: string): void {
532
+ if (!own(fields, name)) {
533
+ throw problem(at, `unknown field "${name}" in ${where}`, "Add it to the agent's fields or fix the slug.");
534
+ }
535
+ }
536
+
537
+ /**
538
+ * A predicate's names against the registries: the fields `equals` and `known`
539
+ * read, and every other key as one of the agent's conditions. A function is
540
+ * code and passes as is. The agent runs this on its own instructions too.
541
+ */
542
+ export function checkPred(value: LoosePred | undefined, at: string, where: string, registries: Registries): void {
543
+ if (value === undefined || typeof value === "function") return;
544
+ const { fields, conditions = {} } = registries;
545
+ for (const [name, arg] of Object.entries(value)) {
546
+ if (name === "equals") {
547
+ if (!isObject(arg)) {
548
+ throw problem(at, `${where}.equals is not an object`, "Write equals as { field: value }.");
549
+ }
550
+ for (const [field, given] of Object.entries(arg)) {
551
+ checkSlug(fields, field, at, `${where}.equals`);
552
+ const def = fields[field];
553
+ if (!matches(def, given)) {
554
+ const { listed, expected, got, fix } = mismatch(def, given);
555
+ throw listed
556
+ ? problem(at, `${where}.equals gives "${field}" ${got}, which is not ${expected}`, fix)
557
+ : problem(at, `${where}.equals gives "${field}" ${article(got)} ${got}, but the field is ${expected}`, `Write ${expected}; values are not coerced.`);
558
+ }
559
+ }
560
+ } else if (name === "known") {
561
+ if (!Array.isArray(arg)) throw problem(at, `${where}.known is not a list`, "Write known as [field, ...].");
562
+ for (const field of arg) checkSlug(fields, String(field), at, `${where}.known`);
563
+ } else if (name === "silenced") {
564
+ if (typeof arg !== "boolean") throw problem(at, `${where}.silenced is not a boolean`, "Write true or false.");
565
+ } else if (!own(conditions, name)) {
566
+ throw problem(
567
+ at,
568
+ `unknown condition "${name}" in ${where}`,
569
+ `Register it in conditions or use ${BUILT_IN_CONDITIONS.join(", ")}.`,
570
+ );
571
+ }
572
+ }
573
+ }
574
+
575
+ // ── Shape ───────────────────────────────────────────────────────────────
576
+
577
+ /**
578
+ * The JSON shape, checked before any name is. Stored rows and generated specs
579
+ * are untrusted: a string where a list belongs used to crash with a raw
580
+ * TypeError naming no flow, or pass and misbehave at run time (`collect:
581
+ * "nome"` read as the fields "n", "o", "m", "e").
582
+ */
583
+ function checkShape(value: unknown): void {
584
+ if (!isObject(value)) throw problem("flow", `is ${show(value)}, not an object`, "Pass the flow itself: { id, name, steps }.");
585
+ const flowAt = typeof value.id === "string" ? `flow "${value.id}"` : "flow";
586
+ if (!Array.isArray(value.steps)) {
587
+ throw problem(flowAt, "has no steps list", "Write steps as a list, even an empty one.");
588
+ }
589
+ text(value, ["description", "anchor"], flowAt);
590
+ for (const key of ["collect", "clearOnStart", "tools"]) listOf(value[key], key, flowAt, "string");
591
+ oneOf(value.onEnd, "onEnd", ON_END, flowAt);
592
+ predShape(value.while, "while", flowAt);
593
+ instructionsShape(value.instructions, flowAt);
594
+
595
+ listOf(value.on, "on", flowAt, "object").forEach((trigger, i) => {
596
+ const at = `${flowAt}, trigger #${i + 1}`;
597
+ if (!isObject(trigger)) return;
598
+ listOf(trigger.message, "message", at, "string");
599
+ listOf(trigger.mention, "mention", at, "string");
600
+ predShape(trigger.if, "if", at);
601
+ const { repeat } = trigger;
602
+ if (repeat !== undefined && repeat !== "once" && repeat !== "always" && !(isObject(repeat) && typeof repeat.cooldown === "string")) {
603
+ throw problem(at, `repeat is ${show(repeat)}`, 'Use "once", "always" or { cooldown: "24h" }.');
604
+ }
605
+ });
606
+
607
+ listOf(value.steps, "steps", flowAt, "object").forEach((step, i) => {
608
+ if (!isObject(step)) return;
609
+ const at = typeof step.id === "string" ? `${flowAt}, step "${step.id}"` : `${flowAt}, step #${i + 1}`;
610
+ stepShape(step, at);
611
+ });
612
+ }
613
+
614
+ /** One step: what it does, and every value the right kind of thing. */
615
+ function stepShape(step: Record<string, unknown>, at: string): void {
616
+ // A step that does none of the five things is one the run walks straight
617
+ // past; one that does two runs only the first, and the other never happens.
618
+ const kinds = bodyKinds(step);
619
+ if (kinds.length === 0) {
620
+ throw problem(
621
+ at,
622
+ "does nothing",
623
+ "A step talks (`prompt` / `collect`), says (`say`), acts (`do`), waits (`wait`) or forks (`if`).",
624
+ );
625
+ }
626
+ if (kinds.length > 1) {
627
+ throw problem(at, `mixes ${and(kinds.map((k) => `"${k}"`))}`, "A step does one thing. Split it into one step per kind.");
628
+ }
629
+ // The spec's `kind` must say what the body does; the Runner reads the body.
630
+ if (step.kind !== undefined && step.kind !== kinds[0]) {
631
+ 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.`);
632
+ }
633
+ text(step, ["say", "prompt", "question"], at);
634
+ listOf(step.collect, "collect", at, "string");
635
+ listOf(step.tools, "tools", at, "string");
636
+ if (step.ask !== undefined && !isObject(step.ask)) {
637
+ throw problem(at, `ask is ${show(step.ask)}, not an object`, 'Write ask as { field: "how to ask" }.');
638
+ }
639
+ if (step.with !== undefined && !isObject(step.with)) {
640
+ throw problem(at, `with is ${show(step.with)}, not an object`, "Write with as { parameter: value }.");
641
+ }
642
+ const { maxAsks } = step;
643
+ if (maxAsks !== undefined && !(typeof maxAsks === "number" && Number.isInteger(maxAsks) && maxAsks >= 1)) {
644
+ throw problem(at, `maxAsks is ${show(maxAsks)}, not a whole number of 1 or more`, "Write a number like 3.");
645
+ }
646
+ if (isObject(step.wait) && typeof step.wait.event !== "string") {
647
+ throw problem(at, "wait has no event", 'Write wait: { event: "name" } to wait for an event, or a duration like "1h".');
648
+ }
649
+ predShape(step.if, "if", at);
650
+ for (const key of ["then", "else", "onFail"]) nextShape(step[key], key, at);
651
+ instructionsShape(step.instructions, at);
652
+ listOf(step.branches, "branches", at, "object").forEach((branch, j) => {
653
+ if (!isObject(branch)) return;
654
+ const where = `branches[${j}]`;
655
+ if (branch.when !== undefined && typeof branch.when !== "string") {
656
+ throw problem(at, `${where}.when is ${show(branch.when)}, not text`, "Write the condition as one sentence.");
657
+ }
658
+ predShape(branch.if, `${where}.if`, at);
659
+ nextShape(branch.then, `${where}.then`, at);
660
+ });
661
+ }
662
+
663
+ /** The kinds a step's body carries, by its keys. A talk step is `collect` with a list, `prompt` with a guideline alone. */
664
+ function bodyKinds(step: Record<string, unknown>): StepKind[] {
665
+ const kinds: StepKind[] = [];
666
+ if (step.collect !== undefined) kinds.push("collect");
667
+ else if (step.prompt !== undefined) kinds.push("prompt");
668
+ if (step.say !== undefined) kinds.push("say");
669
+ if (step.do !== undefined) kinds.push("do");
670
+ if (step.wait !== undefined) kinds.push(typeof step.wait === "string" ? "wait" : "waitEvent");
671
+ if (step.if !== undefined) kinds.push("if");
672
+ return kinds;
673
+ }
674
+
675
+ function instructionsShape(value: unknown, at: string): void {
676
+ listOf(value, "instructions", at, "object").forEach((ins, i) => {
677
+ if (!isObject(ins)) return;
678
+ const where = `instructions[${i}]`;
679
+ oneOf(ins.kind, `${where}.kind`, INSTRUCTION_KINDS, at);
680
+ if (typeof ins.prompt !== "string") {
681
+ throw problem(at, `${where}.prompt is ${show(ins.prompt)}, not text`, "Write the rule as a sentence.");
682
+ }
683
+ if (ins.when !== undefined && typeof ins.when !== "string") listOf(ins.when, `${where}.when`, at, "string");
684
+ predShape(ins.if, `${where}.if`, at);
685
+ });
686
+ }
687
+
688
+ /** A step id, `"end"`, `{ step, clear? }` or `{ flow, input? }`. */
689
+ function nextShape(value: unknown, where: string, at: string): void {
690
+ if (value === undefined || typeof value === "string") return;
691
+ if (isObject(value) && (typeof value.step === "string" || typeof value.flow === "string")) {
692
+ listOf(value.clear, `${where}.clear`, at, "string");
693
+ return;
694
+ }
695
+ throw problem(at, `${where} is ${show(value)}, not a step id or a target`, 'Write a step id, "end", { step: "id" } or { flow: "id" }.');
696
+ }
697
+
698
+ /** A condition object, or code on a typed flow. */
699
+ function predShape(value: unknown, where: string, at: string): void {
700
+ if (value === undefined || typeof value === "function" || isObject(value)) return;
701
+ throw problem(at, `${where} is ${show(value)}, not a condition`, 'Write it as an object, e.g. { known: ["nome"] }.');
702
+ }
703
+
704
+ /** Each named key, when set, must be text. */
705
+ function text(owner: Record<string, unknown>, keys: string[], at: string): void {
706
+ for (const key of keys) {
707
+ const value = owner[key];
708
+ if (value !== undefined && typeof value !== "string") {
709
+ throw problem(at, `${key} is ${show(value)}, not text`, `Write ${key} as a string.`);
710
+ }
711
+ }
712
+ }
713
+
714
+ function oneOf(value: unknown, where: string, allowed: string[], at: string): void {
715
+ if (value === undefined || (typeof value === "string" && allowed.includes(value))) return;
716
+ throw problem(at, `${where} is ${show(value)}, which is not one of ${allowed.map((v) => `"${v}"`).join(", ")}`, "Use one of them.");
717
+ }
718
+
719
+ /** A list whose items are all strings or all objects; absent reads as empty. */
720
+ function listOf(value: unknown, where: string, at: string, item: "string" | "object"): unknown[] {
721
+ if (value === undefined) return [];
722
+ if (!Array.isArray(value)) {
723
+ const example = item === "string" && typeof value === "string" ? `${where}: ${JSON.stringify([value])}` : `${where} as a list`;
724
+ throw problem(at, `${where} is ${show(value)}, not a list`, `Write ${example}.`);
725
+ }
726
+ value.forEach((entry, i) => {
727
+ if (item === "string" ? typeof entry !== "string" : !isObject(entry)) {
728
+ 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"}.`);
729
+ }
730
+ });
731
+ return value;
732
+ }
733
+
734
+ /** A plain object: not null, not a list, not a function. */
735
+ function isObject(value: unknown): value is Record<string, unknown> {
736
+ return typeof value === "object" && value !== null && !Array.isArray(value);
737
+ }
738
+
739
+ /** A wrong value as an error names it: short scalars verbatim, anything else by kind. */
740
+ function show(value: unknown): string {
741
+ if (typeof value === "string") return JSON.stringify(value.length > 40 ? `${value.slice(0, 37)}...` : value);
742
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
743
+ if (value === undefined) return "missing";
744
+ if (value === null) return "null";
745
+ return Array.isArray(value) ? "a list" : typeof value === "function" ? "a function" : "an object";
746
+ }
747
+
748
+ /** `"a"`, `"a" and "b"`, `"a", "b" and "c"`. */
749
+ function and(items: string[]): string {
750
+ return items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
751
+ }
752
+
490
753
  function matchesParam(def: ParamDef, value: unknown): boolean {
491
754
  if (def.type === "array") return Array.isArray(value) && value.every((item) => matches(def.items, item));
492
755
  return matches(def, value);
@@ -506,12 +769,33 @@ function matches(def: ScalarDef, value: unknown): boolean {
506
769
  return typeof value !== "boolean" && def.enum.includes(value);
507
770
  }
508
771
 
772
+ /**
773
+ * What a rejected value should have been and what it was. A value of the right type that is
774
+ * not a listed one names the listed values: "must be a string, got string" would say nothing.
775
+ */
776
+ function mismatch(def: ParamDef, value: unknown): { listed: boolean; expected: string; got: string; fix: string } {
777
+ const scalar = def.type === "array" ? def.items : def;
778
+ const { enum: allowed, ...typeOnly } = scalar;
779
+ const items = def.type === "array" && Array.isArray(value) ? value : [value];
780
+ const off = allowed ? items.findIndex((item) => matches(typeOnly, item) && !matches(scalar, item)) : -1;
781
+ if (!allowed || off === -1) {
782
+ return { listed: false, expected: describeDef(def), got: describe(value), fix: "Values are not coerced; write the right type." };
783
+ }
784
+ const list = (v: unknown) => JSON.stringify(v);
785
+ return { listed: true, expected: `one of ${allowed.map(list).join(", ")}`, got: list(items[off]), fix: "Use one of the listed values." };
786
+ }
787
+
509
788
  function describe(value: unknown): string {
510
789
  return Array.isArray(value) ? "list" : value === null ? "null" : typeof value;
511
790
  }
512
791
 
513
792
  function describeDef(def: ParamDef): string {
514
- return def.type === "array" ? `a list of ${def.items.type}` : `a ${def.type}`;
793
+ return def.type === "array" ? `a list of ${def.items.type}s` : `${article(def.type)} ${def.type}`;
794
+ }
795
+
796
+ /** "an integer", "a string". A vowel test rather than one hard-coded type, so a new type reads right for free. */
797
+ function article(type: string): string {
798
+ return /^[aeiou]/.test(type) ? "an" : "a";
515
799
  }
516
800
 
517
801
  // ── flowSpecSchema ──────────────────────────────────────────────────────
@@ -580,6 +864,8 @@ export function flowSpecSchema(registries: Registries): StructuredSchema {
580
864
  const branches = orNull(
581
865
  list(union([closed({ when: STRING, then: next }), closed({ if: condition, then: next })]), "Exits judged while the step asks"),
582
866
  );
867
+ // No understand call judges a wait, so its branches are code only.
868
+ const waitBranches = orNull(list(closed({ if: condition, then: next }), "Exits checked by code when the lead replies before the wait ends"));
583
869
  const stepBase = { id: STRING, label: orNull(STRING), then: nextOrNull };
584
870
  const step = union([
585
871
  closed({ ...stepBase, kind: enumOf(["prompt"]), prompt: { type: "string", description: "Guideline for the AI's next message" }, branches }),
@@ -589,6 +875,7 @@ export function flowSpecSchema(registries: Registries): StructuredSchema {
589
875
  kind: enumOf(["collect"]),
590
876
  collect: { ...slugList, description: "Fields the AI asks for until they are known" },
591
877
  prompt: orNull(STRING),
878
+ question: orNull({ type: "string", description: "A fixed first question, sent word for word; later asks are the AI's" }),
592
879
  maxAsks: orNull({ ...INTEGER, description: "Times a field may be asked before it is skipped; default 3" }),
593
880
  branches,
594
881
  }),
@@ -608,7 +895,7 @@ export function flowSpecSchema(registries: Registries): StructuredSchema {
608
895
  wait: { ...duration, description: "Park this long; then = time passed, else = the lead replied" },
609
896
  businessHours: orNull(BOOLEAN),
610
897
  else: nextOrNull,
611
- branches,
898
+ branches: waitBranches,
612
899
  }),
613
900
  eventNames.length
614
901
  ? closed({
@@ -634,9 +921,10 @@ export function flowSpecSchema(registries: Registries): StructuredSchema {
634
921
  on: orNull(list(trigger, "What starts a run; null = started by hand")),
635
922
  anchor: orNull({ type: "string", description: "'session' (default) or a host anchor such as 'lead'" }),
636
923
  while: orNull({ ...condition, description: "The run ends when this stops holding" }),
924
+ 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" }),
637
925
  clearOnStart: slugList && orNull({ ...slugList, description: "Fields to forget when a run starts" }),
638
926
  steps: list(step, "In order; a run moves to the next step unless `then` says otherwise"),
639
- onEnd: orNull(enumOf(["end", "stay", "reset"], "After the last step: end the run, stay on it, or reset to the first")),
927
+ 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")),
640
928
  instructions: orNull(list(instruction, "Rules that apply only inside this flow")),
641
929
  });
642
930
  }
@@ -685,6 +973,10 @@ function orNull(schema: StructuredSchema): StructuredSchema {
685
973
 
686
974
  // ── Shared helpers ──────────────────────────────────────────────────────
687
975
 
976
+ function questionWithoutCollect(at: string): FlowConfigurationError {
977
+ 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.");
978
+ }
979
+
688
980
  function problem(at: string, what: string, fix: string): FlowConfigurationError {
689
981
  return new FlowConfigurationError(`[FlowConfigurationError] ${at}: ${what}. ${fix}`);
690
982
  }
@@ -112,7 +112,7 @@ function checkRun(raw: unknown, index: number, bad: Bad): Run {
112
112
 
113
113
  const status = text("status");
114
114
  if (!RUN_STATUS.has(status)) throw bad(`${at}.status is "${status}"`);
115
- const { stepId, hop, outcomes, input, waiting, suspendedAt } = raw;
115
+ const { stepId, hop, outcomes, input, waiting, suspendedAt, staying } = raw;
116
116
  if (stepId !== null && typeof stepId !== "string") throw bad(`${at}.stepId is ${describe(stepId)}, expected text or null`);
117
117
  if (!isWhole(hop)) throw bad(`${at}.hop is ${describe(hop)}, expected a whole number`);
118
118
  if (!Array.isArray(outcomes)) throw bad(`${at}.outcomes is ${describe(outcomes)}, expected a list`);
@@ -139,6 +139,7 @@ function checkRun(raw: unknown, index: number, bad: Bad): Run {
139
139
  if (input !== undefined) run.input = input;
140
140
  if (waiting !== undefined) run.waiting = waiting as Run["waiting"];
141
141
  if (typeof suspendedAt === "string") run.suspendedAt = suspendedAt;
142
+ if (staying === true) run.staying = true;
142
143
  return run;
143
144
  }
144
145