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

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 +42 -4
  21. package/dist/cjs/core/Runner.d.ts.map +1 -1
  22. package/dist/cjs/core/Runner.js +292 -72
  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 +60 -21
  26. package/dist/cjs/core/Speak.js.map +1 -1
  27. package/dist/cjs/core/Understand.d.ts +6 -3
  28. package/dist/cjs/core/Understand.d.ts.map +1 -1
  29. package/dist/cjs/core/Understand.js +34 -55
  30. package/dist/cjs/core/Understand.js.map +1 -1
  31. package/dist/cjs/core/contracts.d.ts +21 -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 +16 -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 +42 -4
  123. package/dist/core/Runner.d.ts.map +1 -1
  124. package/dist/core/Runner.js +293 -73
  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 +61 -22
  128. package/dist/core/Speak.js.map +1 -1
  129. package/dist/core/Understand.d.ts +6 -3
  130. package/dist/core/Understand.d.ts.map +1 -1
  131. package/dist/core/Understand.js +34 -55
  132. package/dist/core/Understand.js.map +1 -1
  133. package/dist/core/contracts.d.ts +21 -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 +16 -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 +13 -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 +9 -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 +290 -69
  246. package/src/core/Speak.ts +64 -21
  247. package/src/core/Understand.ts +35 -55
  248. package/src/core/contracts.ts +21 -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 +16 -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
@@ -7,7 +7,7 @@ order: 4
7
7
 
8
8
  # Field collection
9
9
 
10
- A field is one piece of data the conversation collects: a name, a company, a budget, a yes or no. You declare each field once, on the agent, with how to ask for it. Steps say which fields they collect. The model asks and extracts. Code decides what is still missing.
10
+ A field is one piece of data the conversation collects: a name, a company, a budget, a yes or no. You declare each field once, on the agent, with how to ask for it. A flow lists the fields it needs, and its steps say which to ask and when. The model asks and extracts. Code decides what is still missing.
11
11
 
12
12
  ## Declared once
13
13
 
@@ -41,12 +41,45 @@ type Data = DataOf<typeof f>;
41
41
  |---|---|
42
42
  | `type` | `'string'`, `'number'`, `'integer'` or `'boolean'`. Values are coerced to it on the way in. |
43
43
  | `enum` | The allowed values. A value outside the list is dropped. It becomes a literal union in `Data`. |
44
+ | `label` | The name a person reads, in an editor or next to a collected value. The model never sees it. |
44
45
  | `description` | What the field means, for the model. |
45
46
  | `ask` | How the model should ask for it. A step may override it. |
46
47
  | `extract` | Where a value may come from: `'anywhere'` or `'asked'`. The default depends on `type`, below. |
47
48
 
48
49
  `f.fields()` binds the data type, so `collect`, `ask`, `clearOnStart`, `{ step, clear }`, `if: { equals }` and an action's `ctx.set()` are all checked against these field names at compile time. The collected values live in `session.data` as a `Partial<Data>`.
49
50
 
51
+ ## A flow's data, a step's questions
52
+
53
+ A scheduling flow needs one set of fields and a triage flow another. The flow's `collect` lists the fields it needs. Each talk step's `collect` says which of them to ask now, in what order: one field, or several when the step's prompt asks them together.
54
+
55
+ ```ts
56
+ import { falai } from "@falai/agent";
57
+
58
+ const f = falai().fields({
59
+ nome: { type: "string", label: "Nome", ask: "Pergunte o nome." },
60
+ dia: { type: "string", label: "Dia", ask: "Pergunte qual dia fica melhor." },
61
+ orcamento: { type: "number", label: "Orçamento" },
62
+ });
63
+
64
+ const agenda = f.flow({
65
+ id: "agenda",
66
+ name: "Agendamento",
67
+ on: [{ message: ["quer agendar uma visita"] }],
68
+ collect: ["nome", "dia", "orcamento"],
69
+ steps: [
70
+ { id: "quem", collect: ["nome"], question: "Claro! Qual é o seu nome?" },
71
+ { id: "quando", prompt: "Ofereça terça ou quinta.", collect: ["dia"] },
72
+ { id: "fim", say: "Combinado, {{data.nome}}. Até {{data.dia}}." },
73
+ ],
74
+ });
75
+
76
+ export { agenda };
77
+ ```
78
+
79
+ No step asks for `orcamento`. It is still on the flow's list, so when the customer mentions a budget while this flow holds the conversation, the value is noted. A field on the list that only an answer can fill (`extract: 'asked'`, every boolean by default) and that no step asks can never be filled; `validateFlow` warns about it.
80
+
81
+ Step one asks with fixed text: `question`. Its first ask goes out word for word, with no model call. It goes out only when every field the step collects is still missing. If the customer's first message already gave the name, the step is skipped. If that message asks something instead, the model answers it first and the question follows, word for word. A later ask is the model's own wording, so a customer who replies with a question gets an answer.
82
+
50
83
  ## Known and pending
51
84
 
52
85
  A field is **known** when its value is not `undefined`, `null` or `''`. Anything else is unknown. There is no "asked but refused" state, only a count.
@@ -69,7 +102,7 @@ Four writers reach `session.data`. Two are the model, two are your code.
69
102
 
70
103
  | Writer | Which fields | When |
71
104
  |---|---|---|
72
- | The understand call | Unknown fields with `extract: 'anywhere'` listed by any talk step of the floor's flow or of a candidate `message` flow | On a message, before runs move |
105
+ | The understand call | Unknown fields with `extract: 'anywhere'` that the floor's flow or a candidate `message` flow lists, in its own `collect` or a talk step's. With nobody on the floor, also the catch-all's (`message: []`) | On a message, before runs move |
73
106
  | The speak call | The speaking step's pending fields, whatever their `extract`, in the envelope `{ message, ...fields }` | When the step speaks |
74
107
  | A tool's `data` | Whatever the tool returns | During a speak round, written as given |
75
108
  | An action's `ctx.set(patch)` | Whatever the action writes | During a `do` step, written as given |
@@ -110,7 +143,7 @@ This split also decides which call spends tokens on what. On a message, the unde
110
143
  { kind: 'collect', status: 'skipped', code: 'max-asks', detail: 'orcamento' }
111
144
  ```
112
145
 
113
- One line per field that ran out of asks, with the field's slug in `detail`. `maxAsks: 1` means "ask once, do not insist". The field stays unknown: a later step may still collect it, and a later run of the flow starts the count again, because `asked` lives on the run.
146
+ One line per field that ran out of asks, with the field's slug in `detail`. `maxAsks: 1` means "ask once, do not insist". The field stays unknown: a later step may still collect it, and a later run of the flow starts the count again, because `asked` lives on the run. A step's fixed `question` counts as one ask. `{ step, clear }` resets the count of the fields it clears, so a confirmation loop asks from scratch each round.
114
147
 
115
148
  ## Known fields are never re-extracted
116
149
 
@@ -157,11 +190,13 @@ Three layers of text shape the question, from general to specific.
157
190
  2. The step's `ask: { orcamento: '…' }`: wins over the field's, for that step only.
158
191
  3. The step's `prompt`: the guideline for the whole reply. Without one, the default is "Collect what is still missing below, in the flow of the conversation, one or two things per message."
159
192
 
160
- All three are templates: `{{data.x}}`, `{{context.x}}` and `{{input.x}}` are filled in before the model reads them. The model sees every pending field of the step with its wording, in collect order, and is told to ask at the pace the prompt sets and to take any value the customer's message already answers.
193
+ A step's `question` skips all three for the first ask: it is the exact text the customer reads. Every later ask goes back to the three layers.
194
+
195
+ All four are templates: `{{data.x}}`, `{{context.x}}` and `{{input.x}}` are filled in before the model reads them. The model sees every pending field of the step with its wording, in collect order, and is told to ask at the pace the prompt sets and to take any value the customer's message already answers.
161
196
 
162
197
  ## What the provider sees
163
198
 
164
- `toWireSchema` in `src/utils/schema.ts` is the only way a field definition reaches a provider. It keeps `type`, `description` and `enum` and strips `ask`, `extract` and `optional`: those are for the framework, not the model. The result is a closed JSON schema (`additionalProperties: false`). In both envelopes every property is required and nullable, so the model must answer each field with a value or `null`. A field name that is not a legal property name for the provider, or is spelled `message`, travels under an alias and is mapped back on the way out.
199
+ `toWireSchema` in `src/utils/schema.ts` is the only way a field definition reaches a provider. It keeps `type`, `description` and `enum` and strips `label`, `ask`, `extract` and `optional`: those are for the framework, not the model. The result is a closed JSON schema (`additionalProperties: false`). In both envelopes every property is required and nullable, so the model must answer each field with a value or `null`. A field name that is not a legal property name for the provider, or is spelled `message`, travels under an alias and is mapped back on the way out.
165
200
 
166
201
  ## Where next
167
202
 
@@ -62,12 +62,12 @@ Code first works out what there is to judge:
62
62
  - **Candidates**: the flow holding the floor, whatever its trigger, plus every `message` flow with a non-empty phrase list whose `if` holds and whose `repeat` allows a start, in flow order. `message: []` catch-alls are never scored.
63
63
  - **Mentions**: every `mention` flow with a non-empty list whose `repeat` allows a start.
64
64
  - **Branches**: the `when` branches of the asking step.
65
- - **Fields**: every unknown field with `extract: 'anywhere'` listed by any talk step of the floor's flow or of a candidate flow.
65
+ - **Fields**: every unknown field with `extract: 'anywhere'` that the floor's flow or a candidate flow lists, in its own `collect` or a talk step's. With nobody on the floor, the catch-all that would take the message adds its fields too, because an opening message often says the most. The fields its first step asks are read by that step's own speak call, so on their own they are not worth a call. A first step with a fixed `question` has no speak call, so its fields do count.
66
66
 
67
67
  Then the shortcuts, each worth zero calls:
68
68
 
69
69
  - Nothing to judge: no call. The catch-all or the idle speaker answers.
70
- - Exactly one eligible `message` flow, nobody on the floor, nothing else to judge: it starts without scoring.
70
+ - Exactly one eligible `message` flow, nobody on the floor, no catch-all that passes, `idle: 'silent'`, nothing else to judge: it starts without scoring. With a catch-all or the idle speaker there, a low score has somewhere to go, so the lone flow is scored.
71
71
  - A floor holder, no other candidate, nothing else to judge: there is nothing to compare.
72
72
 
73
73
  Otherwise one call, `schemaName: 'understand'`, with one envelope: `{ flows: { id: 0–100 }, mentions: { id: boolean }, extract: { id: { … } }, branches: { q1: boolean }, fields: { field: value } }`. Only the sections with something to judge are present; inside a section every property is required and nullable, so the model must answer each one. Branch keys travel as short aliases (`q1`, `q2`) because a run id is not a legal property name; they are mapped back to `${runId}/${stepId}/${index}` when the reply is parsed. A reply with no usable JSON is logged and treated as an empty judgement; the call still counts. A provider failure here throws `ProviderError`: nothing ran, nothing was saved, and the host retries the input.
@@ -76,7 +76,7 @@ Otherwise one call, `schemaName: 'understand'`, with one envelope: `{ flows: { i
76
76
 
77
77
  Code applies the judgement in a fixed order.
78
78
 
79
- 1. **Routing**, skipped when a run took the floor in Ingest. With an asking run, another flow takes over only when its score is at least 40 and beats the asker's by at least 15. With no asker: a single eligible flow starts as is; otherwise the best score at 40 or above starts, else the first `message: []` catch-all, else nobody, and the idle speaker answers in phase 6. A `suspended` run of the chosen flow resumes instead of a new one starting.
79
+ 1. **Routing**, skipped when a run took the floor in Ingest. With an asking run, another flow takes over only when its score is at least 40 and beats the asker's by at least 15. With no asker: the best score at 40 or above starts, else the first `message: []` catch-all, else nobody, and the idle speaker answers in phase 6. The one exception is a single eligible flow with no catch-all passing and `idle: 'silent'`: it starts as is. A `suspended` run of the chosen flow resumes instead of a new one starting.
80
80
  2. **Mention runs** start in flow order. A `mention: []` trigger is a code-only detector: it goes through the start checks on every message without the call, so a blocked `repeat` is logged as a skip. A trigger's `extract` values become the run's `input`.
81
81
  3. **The routed run** starts or resumes and holds the floor. A run that starts applies `clearOnStart` now, before extracted values land.
82
82
  4. **Fields** from the envelope are written one at a time, after validation. An unknown field name is dropped (`code: 'unknown-field'`), a value that does not fit the type is dropped (`code: 'bad-value'`), a value outside `enum` is dropped (`code: 'not-in-enum'`). Strings are coerced to numbers and booleans on the way in.
@@ -84,13 +84,13 @@ Code applies the judgement in a fixed order.
84
84
 
85
85
  ## 5. Run
86
86
 
87
- If no run is asking, the most recently suspended one returns to asking. Then every live run is moved in turn; a child started by `then: { flow }` joins the queue. A run moves only if it can: `waiting` and `suspended` runs stay put, and an asking run re-speaks only on a message, never on a wake or an event.
87
+ If no run is asking, the most recently suspended one returns to asking. Then every live run is moved in turn; a child started by `then: { flow }` joins the queue. On a message that nothing has answered once the queue is empty, with nobody asking and no `silenced`, the most recently suspended run returns to asking and moves too, and so on down the stack until one answers: an asker that moved on without a word hands the message back to the run it suspended. A run that returns to asking on a message has its step's `if` branches judged first, as Decide does for the asker. A run moves only if it can: `waiting` and `suspended` runs stay put, and an asking run re-speaks only on a message, never on a wake or an event.
88
88
 
89
89
  Before a run moves, its premise is re-checked with this turn's context: `while` when the flow has one, otherwise the trigger's `if`. A false premise ends the run: `code: 'premise-changed'`. A silence-started run moved by a wake also ends when the customer has written since it started: `code: 'customer-replied'`. A flow the agent no longer has ends the run with `code: 'flow-gone'`; a missing step, `code: 'step-gone'`.
90
90
 
91
91
  Then the run walks its steps until one stops it.
92
92
 
93
- - **Talk** (`prompt` / `collect`). Pending is `collect` minus known minus at `maxAsks`. A collect step with nothing pending is skipped with no call (`code: 'already-known'`) and the run continues. Under `silenced`, a resuming asker stays asking and any other run ends `code: 'silenced'` (`detail` = your reason). Otherwise every other asking run is suspended, this run becomes `asking`, and it is the turn's speaker, unless speaking already happened this turn, in which case it waits for the next message.
93
+ - **Talk** (`prompt` / `collect`). Pending is `collect` minus known minus at `maxAsks`. A collect step with nothing pending is skipped with no call (`code: 'already-known'`) and the run continues, except on the step an `onEnd: 'stay'` run stays on: that one answers anyway. Under `silenced`, a resuming asker stays asking and any other run ends `code: 'silenced'` (`detail` = your reason). Otherwise every other asking run is suspended, this run becomes `asking`, and it is the turn's speaker, unless speaking already happened this turn, in which case it waits for the next message.
94
94
  - **Say.** The text goes to `messages[]` as `kind: 'verbatim'` with the pending `afterMs`. `once` writes a claim; a repeat is `code: 'already-sent'`. Under `silenced` the run ends `code: 'silenced'` (`detail` = your reason).
95
95
  - **Do.** The action runs now, with `with` rendered against `data`, `context` and `input`, under `key = ${runId}:${stepId}:${visit}`. `{ ok }` continues (`spoke: true` makes this run the one that answered); `{ skipped }` continues (`code: 'action-skipped'`); `{ failed }` takes `onFail` or continues (`code: 'action-failed'`); `{ defer }` parks the run under a new wake and re-runs the same step, same key, when it fires. An unknown action is `code: 'action-failed'` with `detail: 'unknown action "notify"'`; a thrown error is `code: 'action-failed'`, the error message in `detail`.
96
96
  - **Wait** (timer). Ten seconds or less, when the next step is a `say` or a talk step: the delay rides on that message as `afterMs` (`code: 'inline-delay'`, `detail: '3000ms'`). Anything else parks the run and adds `{ key, at }` to `schedule[]`, with `at` moved forward to the next business hour when the step sets `businessHours: true`.
@@ -115,6 +115,7 @@ The prompt is built per call, in `src/core/Speak.ts`, in this order:
115
115
  - the known fields, as settled facts
116
116
  - the instructions whose `if` holds: agent, then flow, then step
117
117
  - the customer's message, or, on anything but a message (a wake, an event, a start), a note that there is no new message and the assistant speaks first
118
+ - what this turn already sent before the reply (a `say`, a fixed question), in order, so the reply does not say it again
118
119
  - the response format
119
120
 
120
121
  The envelope is `{ message, ...pending fields of this step }`, every property required and nullable, so one call both answers and extracts. Tools run in rounds. Each round is one call; the model may call tools, their results go back as history, and it is asked again. After `maxToolLoops` rounds (default 5; `0` disables tools) it is asked once more without tools, so a message always comes back. Field values merge across rounds, last one wins. A provider failure or an empty message returns `deferred` instead of throwing; phase 7 re-parks the step.
@@ -123,15 +124,15 @@ The envelope is `{ message, ...pending fields of this step }`, every property re
123
124
 
124
125
  The one place the spoken result is applied.
125
126
 
126
- **Spoken.** The message goes to `messages[]` as `kind: 'ai'` with `key = ${runId}:${stepId}:${visit}`, or `idle:<trigger key>` for the idle speaker. Envelope values are validated and written like phase 4; tool `data` patches are written as given. Pending is recomputed. Each field still pending gets `asked + 1` and the run stays `asking`. Nothing pending: a field that hit `maxAsks` is reported (`code: 'max-asks'`, one line per field), the run takes `then` and keeps moving this turn, except that a talk step reached now waits for the next message.
127
+ **Spoken.** The message goes to `messages[]` as `kind: 'ai'` with `key = ${runId}:${stepId}:${visit}`, or `idle:<trigger key>` for the idle speaker. Envelope values are validated and written like phase 4; tool `data` patches are written as given. Pending is recomputed. Each field still pending gets `asked + 1` and the run stays `asking`. Nothing pending: a field that hit `maxAsks` is reported (`code: 'max-asks'`, one line per field), the run takes `then` and keeps moving this turn, except that a talk step reached now waits for the next message. A run staying on its step (`onEnd: 'stay'`) takes no `then`: it stays there for the next message, one visit later. A run that reaches the end of an `onEnd: 'stay'` flow goes back to the last talk step it took; when another run is asking, it waits `suspended` behind it, unless this message was routed to it.
127
128
 
128
- **Deferred.** A failure a wait can fix — the provider was down, slow or rate-limited — re-parks the talk step under `${runId}:${stepId}:${visit}:retry:${atMs}`: +1m, +5m, +15m, +1h, +6h, then the run ends `failed`. A failure it cannot fix — a rejected key, a prompt past the context window — ends the run at once. The session is saved with everything phase 5 did. The retry wake re-runs the step under the same key, so the `do` steps before it do not run again.
129
+ **Deferred.** A failure a wait can fix — the provider was down, slow or rate-limited — re-parks the talk step under `${runId}:${stepId}:${visit}:retry:${atMs}`: +1m, +5m, +15m, +1h, +6h, then the run ends `failed`. A failure it cannot fix — a rejected key, a prompt past the context window — ends the run at once. The session is saved with everything phase 5 did. The retry wake re-runs the step under the same key, so the `do` steps before it do not run again. When the host's history still ends with the customer's message, the retry's speak call is told that message has no answer yet, rather than that the assistant speaks first. Only the retry does this: any other wake speaks first, even when the history ends with a customer message, because a host may have skipped that message on purpose.
129
130
 
130
131
  Then three bookkeeping moves. `lastAssistantAt` is set when anything went out. The most recently suspended run resumes when nobody is asking. And when the assistant spoke last, every `silence` flow whose `if` and `repeat` allow it gets a wake at `lastAssistantAt + silence`, key `silence:${flowId}:${sessionId}:${lastAssistantAtMs}`, with `replaces` naming the previous one.
131
132
 
132
133
  ## 8. Return
133
134
 
134
- `TurnResult`: `session` (version unchanged; the host bumps it on save), `changed`, `messages` in emission order, `schedule`, `outcomes`, `started`, `ended` (each with a reason), `skipped` (triggers that matched but did not start, with the reason), `llmCalls`. `changed` is `false` when the input was ignored — a repeated message id, a wake with no session, a wake nothing is waiting for — or when the session came back identical with no message, no wake, no outcome and no skip to show for the turn.
135
+ `TurnResult`: `session` (version unchanged by the turn; `Store.save` bumps it and returns the saved session), `changed`, `messages` in emission order, `schedule`, `outcomes`, `started`, `ended` (each with a reason), `skipped` (triggers that matched but did not start, with the reason), `llmCalls`. `changed` is `false` when the input was ignored — a repeated message id, a wake with no session, a wake nothing is waiting for — or when the session came back identical with no message, no wake, no outcome and no skip to show for the turn.
135
136
 
136
137
  ## The budget
137
138
 
@@ -139,12 +140,14 @@ Every row but the last is asserted by a scenario in `tests/scenarios/`; the comp
139
140
 
140
141
  | Turn | Calls | Why | Scenario |
141
142
  |---|---|---|---|
142
- | A message with a floor holder or several candidate flows | 2 | understand, then speak | S1 |
143
- | A message when one flow is eligible and nothing else needs judging | 1 | speak only | S0, S1 |
143
+ | A message with a floor holder, several candidate flows, or one candidate that a catch-all or the idle speaker could stand in for | 2 | understand, then speak | S1, S13 |
144
+ | A message with nothing to judge: a catch-all alone with nothing to learn beyond what its first step asks, a floor holder alone, or one flow with `idle: 'silent'` and no catch-all | 1 | speak only | S0, S1, S8, S14 |
144
145
  | A message with no flows, answered by the idle speaker | 1 | speak only | S9 |
145
146
  | A message where a mention flow's `say` answers | 1 | understand only; the floor's talk is skipped | S4 |
146
147
  | Each tool round | +1 | one more speak call | S8, S9 |
147
148
  | A wake or start that reaches a talk step | 1 | speak only; there is no message to understand | S2, S5, S12 |
149
+ | A step's first ask with a fixed `question` | 0 | the text goes out as written | S14 |
150
+ | The same, when the customer's message asks something | 1 | the answer, then the text as written | S14 |
148
151
  | A wake or start that runs only `do`, `wait` and `if` steps | 0 | code only | S5 (the start; its defer test covers the wake) |
149
152
  | Any input under `silenced` (a plain reason) | 0 | `do` steps run, nobody speaks; `{ reason, understand: true }` still spends the understand call | S2, S12 |
150
153
  | A message with `idle: 'silent'` and no eligible flow | 0 | nothing to judge, nobody speaks | S9 |
@@ -68,7 +68,7 @@ Every start, whatever the trigger, goes through the same checks in `Runner.start
68
68
  At most one run in a session is `asking`. That run holds the floor: its talk step spoke last, and the next message is read as its answer.
69
69
 
70
70
  - A talk step reached by any other run suspends the asker (`status: 'suspended'`, `suspendedAt: now`) and takes the floor. A follow-up nudge that fires while triage is mid-question does exactly this.
71
- - Whenever nobody is asking, at the start of phase 5 and again after Speak, the **most recently suspended** run returns to asking. It is a stack: `suspendedAt` decides, not `startedAt`. `tests/runner.test.ts` ("the floor") pins this with three runs.
71
+ - Whenever nobody is asking, at the start of phase 5 and again after Speak, the **most recently suspended** run returns to asking. On a message, it also happens in phase 5 once every run has moved, when nothing has answered yet; the resumed run then answers it, or the next one down the stack does if it too moves on without a word. It is a stack: `suspendedAt` decides, not `startedAt`. `tests/runner.test.ts` ("the floor") pins this with three runs.
72
72
  - An asking run re-speaks only on a message. Wakes and events leave it alone.
73
73
  - On a message, routing may move the floor to another flow: it needs a score of at least 40 and at least 15 above the asker's. Then that flow's suspended run resumes, or a new run starts. Routing never takes the floor from a run that took it in Ingest (a resolved wait, a wake). [Triggers](../guides/triggers.md) has the routing rules.
74
74
  - One answer per message. When a run other than the floor holder answered this turn (a `say`, or a `do` returning `spoke: true`), the floor's talk is skipped (`code: 'another-reply'`) and the asker stays asking.
@@ -81,7 +81,7 @@ A `wait` step parks the run. There are two kinds.
81
81
 
82
82
  - **Ten seconds or less**, when the next step is a `say` or a talk step: no wake at all. The delay rides on that message as `afterMs` (`wait: '3s'` gives `afterMs: 3000`) and the run keeps moving. Any other short wait behaves like a long one.
83
83
  - **Longer**: the run parks with `waiting: { kind: 'timer', key, until, setAt }`, and `schedule[]` gets `{ key, at }`. `businessHours: true` moves `at` forward to the next open hour, using the agent's `businessHours` function.
84
- - A message, or an inbound event, while parked: every timer wait with an `else` takes it at Ingest, outcome `code: 'replied'`. An `if` branch on the wait step is checked first and wins over `else`; `when` branches on a wait step are not judged.
84
+ - A message, or an inbound event, while parked: every timer wait with an `else` takes it at Ingest, outcome `code: 'replied'`. An `if` branch on the wait step is checked first and wins over `else`; `when` branches on a wait step are not judged, and `validateFlow` warns about them.
85
85
  - The wake fires: `code: 'no-reply'`, `then`. Unless the customer wrote after `waiting.setAt` and the step has an `else`: then `code: 'replied'`, `else`. The reply beat the job.
86
86
 
87
87
  **Event**: `{ wait: { event: 'meeting_booked', upTo?: '7d' }, else? }`. The event arrives: `code: 'event-arrived'`, `then`. `upTo` passes (default 30 days): `code: 'no-event'`, `else`, or the run ends when there is no `else`.
@@ -53,7 +53,7 @@ A `do` step fills the parameters with `with`. When the agent is built, `validate
53
53
  - a value has the wrong type; nothing is coerced, so `"3"` is not a number. A template such as `"{{data.cep}}"` is a string, so templates can only fill string parameters (and the items of a string array);
54
54
  - `with` names a parameter the action does not have.
55
55
 
56
- Templates in `with` are rendered right before `run` is called, against `data` (collected fields), `context` (this turn's host context) and `input` (the run's input). A path that resolves to nothing keeps its `{{...}}`, so a typo stays visible.
56
+ Templates in `with` are rendered right before `run` is called, against `data` (collected fields), `context` (this turn's host context) and `input` (the run's input). An unknown path keeps its `{{...}}`, so a typo stays visible. A blank one drops out, and the space or comma it left behind goes with it — an empty string, or a path that walks through a `null` such as `{{context.lead.name}}` when there is no lead.
57
57
 
58
58
  ## What `run` sees
59
59
 
@@ -111,7 +111,7 @@ const enviarTemplate = f.action({
111
111
  });
112
112
  ```
113
113
 
114
- **`defer`** is for "not now": no credits, a rate limit, a window that is closed. The run parks under the wake key `<runId>:<stepId>:<atMs>` and the host schedules it like any other wake. When it fires, the step runs again at the same visit, so `ctx.key` is the same. The outcome carries your `detail` and the `until` time.
114
+ **`defer`** is for "not now": no credits, a rate limit, a window that is closed. The run parks under the wake key `<runId>:<stepId>:<atMs>` and the host schedules it like any other wake. When it fires, the step runs again at the same visit, so `ctx.key` is the same. The outcome carries your `detail` and the `until` time. A `defer` that is not a duration (`"2 minutos"`) cannot park anything, so the step fails instead, with `code: 'action-failed'` and a `detail` that quotes the value; the handler has already run, so throwing would make every replay repeat it.
115
115
 
116
116
  **`spoke: true`** says the action itself sent something to the customer, a template through the channel for example. The framework then treats the turn as the assistant having spoken: `lastAssistantAt` is stamped and silence flows are armed. On a message turn, it also means another run's talk step does not speak (`code: 'another-reply'`): one answer per message.
117
117
 
@@ -64,8 +64,8 @@ console.log(t2.messages.map((m) => m.text)); // ["Claro, vou chamar alguém da e
64
64
 
65
65
  `branches` is allowed on two step kinds:
66
66
 
67
- - A talk step that collects (`collect`, with or without a `prompt`). Both `when` and `if` branches work while it asks. A `prompt` step with no `collect` speaks once and moves on in the same turn, so its branches are only judged in the rare turn where it is still asking: the last step of an `onEnd: 'stay'` flow, or a turn where another run answered the customer first.
68
- - A timer `wait` step (`wait: '2d'`). Only `if` branches are judged there.
67
+ - A talk step that collects (`collect`, with or without a `prompt`). Both `when` and `if` branches work while it asks. A `prompt` step with no `collect` speaks once and moves on in the same turn, so its branches are only judged in the rare turn where it is still asking: the talk step an `onEnd: 'stay'` flow stays on, or a turn where another run answered the customer first. On the step a run stays on, an `if` branch that leads to `'end'` or to a step the run has already been through is not taken: that path already ran, and the fact would still hold on every message. Write a restart there as a `when`.
68
+ - A timer `wait` step (`wait: '2d'`). Only `if` branches are judged there; `validateFlow` rejects a `when` branch on a wait.
69
69
 
70
70
  `say`, `do`, `if` and `wait: { event }` steps have no branches. A code fork between them is an `if` step.
71
71
 
@@ -79,6 +79,8 @@ console.log(t2.messages.map((m) => m.text)); // ["Claro, vou chamar alguém da e
79
79
 
80
80
  If no branch holds, the step carries on: it speaks again with what is still pending, or completes when its fields are known.
81
81
 
82
+ A suspended run that gets the conversation back in the middle of a turn, because the run that was asking finished without a word, is checked the same way before it speaks, but only its `if` branches: the model was not asked about its `when` branches this turn.
83
+
82
84
  **On a `wait` step**, the branches are judged only when the customer replies while the run is parked, which is also when `else` applies. The first `if` branch that holds wins over `else`. A `wait` with no `else` ignores the reply, branches included. When the timer fires, branches are not consulted: the run takes `then`, or `else` if the customer wrote after the wait was set. The outcome line reads `code: 'replied'` or `code: 'no-reply'`.
83
85
 
84
86
  ```ts
@@ -34,13 +34,13 @@ With `maxTokens: 2000` the turn compacts when the history is estimated at 1600 t
34
34
 
35
35
  | Field | Meaning | Default |
36
36
  |---|---|---|
37
- | `maxTokens` | the token budget for the history | required |
37
+ | `maxTokens` | the token budget for the history; more than 0 | required |
38
38
  | `compactionThreshold` | compact when the estimate reaches this share of `maxTokens`; between 0.5 and 0.95 | `0.8` |
39
39
  | `preserveRecentCount` | the newest messages are never changed or removed; at least 2 | `4` |
40
40
  | `maxToolResultChars` | characters kept of a tool result before it is cut; more than 0 | `5000` |
41
41
  | `enabled` | `false` turns compaction off without removing the config | `true` when the config is present |
42
42
 
43
- A value out of range throws at construction, as a plain `Error`: `compactionThreshold must be between 0.5 and 0.95, got 2`, `preserveRecentCount must be >= 2, got 1`, `maxToolResultChars must be > 0, got 0`.
43
+ A value out of range throws at construction, as a plain `Error` that names the value and the fix, for example `[CompactionEngine] compactionThreshold is 2: it must be between 0.5 and 0.95. Use 0.8 unless you measured otherwise.` The same goes for a `maxTokens` of 0 or less, a `preserveRecentCount` under 2 and a `maxToolResultChars` of 0 or less.
44
44
 
45
45
  ## When it runs
46
46
 
@@ -151,7 +151,7 @@ const suporte = f.flow({
151
151
  });
152
152
  ```
153
153
 
154
- What a `when` costs: the understand call happens at most once per turn, and every `when` branch rides in it. When that call would not happen otherwise (a single message flow, nothing to extract, no mention flows), a `when` branch alone makes the turn spend it. An instruction's `when` costs nothing extra: it is text inside the speak prompt.
154
+ What a `when` costs: the understand call happens at most once per turn, and every `when` branch rides in it. When that call would not happen otherwise (no other flow to score, nothing to extract, no mention flows), a `when` branch alone makes the turn spend it. An instruction's `when` costs nothing extra: it is text inside the speak prompt.
155
155
 
156
156
  A `when` only makes sense where there is fresh customer text. Branches on a `wait` step are judged when the customer replies, by code only: an `if` branch there works, a `when` branch is listed by the type but never asked.
157
157
 
@@ -80,6 +80,8 @@ The speak call phrases the reply. If the provider fails here, or answers with an
80
80
 
81
81
  A failure that waits returns with an outcome `{ kind: "prompt" | "collect", status: "deferred", code, until }`, a `schedule[]` entry keyed `${runId}:${stepId}:${visit}:retry:${atMs}`, and `llmCalls` counting the call that failed. The backoff is 1 minute, then 5, 15, an hour, six hours (`RETRY_BACKOFF` in `src/core/Runner.ts`); the attempt number is the count of trailing `deferred` outcomes for that step on that run. A provider that stated a reset later than the next rung is woken at the reset instead. When the wake fires, the step runs again at the same visit, so its message carries the same key it would have carried the first time.
82
82
 
83
+ **The provider's own answer wins.** The table reads the failure's kind, which is an inference. When the provider sends `x-should-retry` it is not one, so that is read first: a `503` saying don't ends the step instead of spending five wakes to be told again, and a rejected request saying do gets its wake instead of ending the run. It arrives on `ProviderError.shouldRetry`.
84
+
83
85
  **The ladder ends.** After the sixth failure on the same step there is no seventh wake: the outcome is `status: "failed"` and the run ends. The same happens at once for a failure no wait can fix — retrying a rejected key or an oversized prompt only spends two model calls to reach the same wall. Both land in `ended` with `reason: "failed"`, so your execution log shows a conversation that stopped and why.
84
86
 
85
87
  Actions that ran earlier in the same turn are not undone; they ran at-least-once and are idempotent on `ctx.key`. `say` steps that went out before the failure are in `messages[]` as usual.
@@ -148,7 +150,7 @@ Most things that go wrong inside a turn become outcome lines, not exceptions:
148
150
 
149
151
  The full detail vocabulary is in [outcomes](../reference/outcomes.md).
150
152
 
151
- Two errors outside the four classes: a `compaction` option out of range throws a plain `Error` at construction (`compactionThreshold must be between 0.5 and 0.95, got 2`), and `PrismaStore` throws a `TypeError` when the client has no model by the given name.
153
+ Two errors outside the four classes: a `compaction` option out of range throws a plain `Error` at construction (`[CompactionEngine] compactionThreshold is 2: it must be between 0.5 and 0.95. Use 0.8 unless you measured otherwise.`), and `PrismaStore` throws a `TypeError` when the client has no model by the given name. [Errors](../reference/errors.md) lists the other wiring errors thrown at construction.
152
154
 
153
155
  ## Catching by class
154
156
 
@@ -144,14 +144,14 @@ Ends this run with `reason: 'flow'` and starts the other flow in the same turn.
144
144
  - has `hop` one higher than the parent. A chain deeper than 5 stops: the child is skipped with `code: 'hop-limit'`.
145
145
  - repeats by default (`'always'`), so a flow may be chained into many times; its claim carries the parent's step key.
146
146
 
147
- `flow` is a template: `{{input.flowId}}` resolves against the run's input and context. A flow id that does not exist lands in `skipped[]` with `code: 'flow-gone'`; a child flow with a live run for the same anchor is skipped with `code: 'already-running'`. This is how one flow hands the conversation to another: the last step of a qualifying flow can `then: { flow: 'agendamento' }`.
147
+ `flow` is a template: `{{input.flowId}}` resolves against the run's input and context. A literal flow id the agent does not have logs a warning when the agent is built and is skipped at run time with `code: 'flow-gone'`; a template that resolves to no flow lands in `skipped[]` with `code: 'flow-gone'`; a child flow with a live run for the same anchor is skipped with `code: 'already-running'`. This is how one flow hands the conversation to another: the last step of a qualifying flow can `then: { flow: 'agendamento' }`.
148
148
 
149
149
  ## `onEnd`: after the last step
150
150
 
151
151
  | `onEnd` | What happens | `ended[].reason` |
152
152
  |---|---|---|
153
153
  | `'end'` (default) | the run ends; the session is idle | `'end'` |
154
- | `'stay'` | the run stays at its last step and runs it again on the next message; each repetition mints a new key | none: the run does not end |
154
+ | `'stay'` | the run goes back to the last talk step it took and answers every later message from there, with a new key each time; the steps after that talk step do not run again | none: the run does not end |
155
155
  | `'reset'` | the run ends and a fresh run of the same flow starts at the first step, data kept, one hop deeper | `'reset'` |
156
156
 
157
157
  ```ts
@@ -169,6 +169,8 @@ const faq = f.flow({
169
169
  });
170
170
  ```
171
171
 
172
+ The talk step does not have to be the last step, and it does not need anything left to collect. A flow that asks for the name, tells the team, and then keeps talking is `steps: [{ id: "quem", collect: ["nome"] }, { id: "avisa", do: "notify" }]` with `onEnd: "stay"`: `avisa` runs once, then `quem` answers every message, even though the name is known. A `say` or a `do` that returned `spoke: true` on the way to the end counts as the answer to that message, so the step waits for the next one. A flow with no talk step ends, as with `'end'`.
173
+
172
174
  `'reset'` is a chain into the same flow, so it costs a hop: a flow with no talk step that resets forever stops at the hop cap instead of spinning.
173
175
 
174
176
  ## `while`: the run's premise
@@ -94,7 +94,7 @@ console.log(outbox[0]?.text, (await store.load("s1"))?.version); // "…", 1
94
94
 
95
95
  Three attempts is enough: a fourth conflict means two workers are firing on the same session, which is a queue problem, not a race.
96
96
 
97
- Three things happen after the save and never before it: messages go out (honouring `afterMs`, keyed by `key`), `schedule[]` entries go into your queue with `jobId = key`, and at fire time you call `turn({ wake: key })` through this same loop. `changed: false` means the input changed nothing (a stale wake, a repeated message id): skip the save and the send.
97
+ Three things happen after the save and never before it: messages go out (honouring `afterMs`, keyed by `key`), `schedule[]` entries go into your queue with the key in the payload and `encodeURIComponent(key)` as the job id (BullMQ refuses a `:` in a custom id, and every key has one), and at fire time you call `turn({ wake: key })` through this same loop. `changed: false` means the input changed nothing (a stale wake, a repeated message id): skip the save and the send.
98
98
 
99
99
  ## What a row holds
100
100
 
@@ -156,7 +156,7 @@ const viaRedis = new RedisStore({ redis, keyPrefix: "agent:", sessionTTL: 7 * 24
156
156
  const viaMongo = new MongoStore({ client: mongo, databaseName: "app" });
157
157
  const sqlite = new SQLiteStore({ db });
158
158
  await sqlite.initialize();
159
- const search = new OpenSearchStore(opensearch, { refresh: "wait_for" });
159
+ const search = new OpenSearchStore({ client: opensearch, refresh: "wait_for" });
160
160
  await search.initialize();
161
161
 
162
162
  console.log([postgres, viaPrisma, viaRedis, viaMongo, sqlite, search].length); // 6
@@ -218,7 +218,7 @@ What each assertion pins down:
218
218
 
219
219
  - `llmCalls` is the budget. A turn that spends more than you scripted throws inside the provider, so an unexpected third call cannot pass silently. `usage` is absent in tests unless your scripted provider reports token counts — real providers do, and the turn adds them up.
220
220
  - `messages[].key` is `${runId}:${stepId}:${visit}`, and `runId` is `${flowId}#${triggerKey}`. The trigger key of a message turn is the message `id` you passed; of a silence wake, the `lastAssistantAt` timestamp in milliseconds.
221
- - `schedule[].key` is what your queue stores as `jobId` and what you pass back as `wake`. The silence key is `silence:${flowId}:${sessionId}:${ms}`; a timer wait's key is `${runId}:${stepId}:${atMs}`.
221
+ - `schedule[].key` is what your queue job carries (its id is the key encoded, since BullMQ refuses a `:` in one) and what you pass back as `wake`. The silence key is `silence:${flowId}:${sessionId}:${ms}`; a timer wait's key is `${runId}:${stepId}:${atMs}`.
222
222
  - `outcomes[]` is the execution log, one line per step. Assert on `code`, which is stable across versions, not on `message`, the English sentence beside it; see [outcomes](../reference/outcomes.md).
223
223
 
224
224
  ## Replaying the same input
@@ -181,7 +181,7 @@ How it works, in order:
181
181
 
182
182
  1. The assistant speaks: a talk step, a `say`, or an action that returns `spoke: true`. The turn stamps `session.lastAssistantAt`.
183
183
  2. For every silence flow whose `if` holds and whose `repeat` allows, the turn puts a wake in `schedule[]`: key `silence:<flowId>:<sessionId>:<lastAssistantAtMs>`, `at` = that time plus the duration. `replaces` names the previous silence key of the same flow so the host can drop the old job. Dropping it is best effort; a stale wake is harmless.
184
- 3. The host enqueues the wake with `jobId = key` and calls `turn({ wake: key })` when it fires.
184
+ 3. The host enqueues the wake with the key in the payload and `encodeURIComponent(key)` as the job id, because BullMQ refuses a `:` in a custom id, and calls `turn({ wake: key })` when it fires.
185
185
  4. The wake is honoured only while the session still shows that silence: the assistant's last message is still the same one and the customer has not written since. Otherwise the turn ends with `code: 'silence-broken'` and `changed: false`.
186
186
  5. The run starts, takes the floor and speaks first. A talk step costs one model call; the prompt tells the model there is no new message from the customer.
187
187
 
@@ -229,7 +229,7 @@ console.log(r.outcomes[0]?.code); // "awaiting-trigger"
229
229
 
230
230
  - `turn({ event, payload, key })` publishes it. The payload becomes the run's `input`: `{{input.stageId}}` in templates, `ctx.input` in actions, `input` in the trigger's `if`. `input` is typed `unknown` there; narrow it before you read it.
231
231
  - `after: '1h'` parks the new run before its first step. The wake key is `<runId>:start:<atMs>`; the outcome line reads `code: 'awaiting-trigger'`. If the same event arrives again for the same flow and anchor while the run is still parked there, the parked run is replaced (`ended[].reason: 'replaced'`). Any other live run makes the new one skip with `code: 'already-running'`.
232
- - `businessHours: true` moves `after` forward to the next working hour.
232
+ - `businessHours: true` moves `after` forward to the next working hour. With no `after`, it holds an event that arrives after hours until the next working hour, and starts at once inside them.
233
233
  - Default `repeat: 'always'`: every event with a new `key` starts a run. The same `key` twice is skipped with `code: 'already-claimed'`, so publishing an event again is safe.
234
234
  - Zero model calls, unless the run reaches a talk step: then the assistant speaks first, one call.
235
235
  - An event may carry a `direction`, which stamps the session as the customer or the assistant speaking. See [Actions and events](actions-and-events.md).
@@ -298,7 +298,7 @@ Inside a session, a flow has at most one live run per anchor. A trigger that fir
298
298
 
299
299
  ## `businessHours`
300
300
 
301
- Timers can wait for working hours. Give the agent a `businessHours` function and set `businessHours: true` where it should apply: a silence trigger, an event's `after`, a `wait` step.
301
+ Timers can wait for working hours. Give the agent a `businessHours` function and set `businessHours: true` where it should apply: a silence trigger, an event trigger (its `after`, or the start itself when it has none), a `wait` step.
302
302
 
303
303
  ```ts
304
304
  import { falai, GeminiProvider } from "@falai/agent";
@@ -338,8 +338,8 @@ Every message turn decides who speaks, in this order:
338
338
  1. **Ingest first.** If the message ends a `wait` with `else` and the run goes on to another step, that run takes the floor and routing is skipped.
339
339
  2. **Eligible flows.** Message flows with a non-empty list whose `if` holds and whose `repeat` allows, in the order you passed them to the agent.
340
340
  3. **The run that is asking keeps priority.** When a run is asking, its flow is scored too. Another flow wins only when its score is at least 15 above the asking flow's and at least 40. Then a suspended run of that flow resumes, or a new run starts; the run that was asking is suspended and comes back when the winner ends.
341
- 4. **One candidate, no floor.** It starts without scoring. If nothing else needs the model this turn (no mention flows, no pending fields to extract), the turn spends no understand call.
342
- 5. **Several candidates, no floor.** The best score wins if it is at least 40. Otherwise the first `message: []` catch-all that passes `if` and `repeat` starts. Otherwise nobody takes the floor.
341
+ 4. **No floor.** The best score wins if it is at least 40. Otherwise the first `message: []` catch-all that passes `if` and `repeat` starts. Otherwise nobody takes the floor. A lone candidate is scored too, so "oi" does not start your scheduling flow just because it is the only one.
342
+ 5. **One candidate and nobody else to answer.** When no catch-all passes and `idle` is `'silent'`, a low score would leave the customer with no reply. So the one candidate starts without a score. If nothing else needs the model this turn (no mention flows, no pending fields to extract), the turn spends no understand call.
343
343
  6. **Nobody has the floor.** The idle speaker answers.
344
344
 
345
345
  Scores come from the understand call, 0 to 100 per candidate. The two thresholds, 40 and 15, are constants in `src/core/Runner.ts`.
@@ -65,12 +65,12 @@ async function onMessage(sessionId: string, context: unknown, history: History,
65
65
  if (r.changed) {
66
66
  await store.save(r.session, session?.version ?? 0); // throws SessionConflictError when another turn saved first: drop this result and run the turn again
67
67
  for (const m of r.messages) await send(m.text, { after: m.afterMs, key: m.key });
68
- for (const s of r.schedule) await queue.add({ jobId: s.key, at: s.at });
68
+ for (const s of r.schedule) await queue.add({ jobId: encodeURIComponent(s.key), at: s.at }); // BullMQ refuses a ':' in a custom id
69
69
  }
70
70
  }
71
71
  ```
72
72
 
73
- Input kinds, one of: `{ message, id?, at? }`, `{ wake }` (a key from `schedule[]`), `{ event, payload?, key }`, `{ start: { flow, input?, key } }`. Pass `context` and `history` on every call, wakes included. Pass `silenced: 'reason'` whenever the assistant must not speak (a human owns the conversation, the channel window is closed, no credits): `do` steps still run, zero model calls, and nothing is said. A run that was already asking stays asking and speaks on the first turn that is not silenced; a run that reaches a new talk or `say` step ends, and the step's outcome is `code: 'silenced'` with your reason in `detail`. Pass `silenced: { reason, understand: true }` to keep the understand call (extraction, mentions) while muting speech.
73
+ Input kinds, one of: `{ message, id?, at? }`, `{ wake }` (a key from `schedule[]`), `{ event, payload?, key }`, `{ start: { flow, input?, key } }`. Pass `context` and `history` on every call, wakes included. Pass `silenced: 'reason'` whenever the assistant must not speak (a human owns the conversation, the channel window is closed, no credits): `do` steps still run, zero model calls, and nothing is said. A run that was already asking stays asking and speaks on the first turn that is not silenced; a run that reaches a new talk or `say` step ends, and the step's outcome is `code: 'silenced'` with your reason in `detail`. Pass `silenced: { reason, understand: true }` to keep the understand call (extraction, mentions) while muting speech. Pass `silenced: { reason, skip: true }` when the gate only stops messages (a closed channel window): the talk or `say` step is skipped and the run goes on to its `then`.
74
74
 
75
75
  `respondStream()` is `turnStream()`: yields `{ delta }` chunks and one `{ done: true, result }`.
76
76
 
@@ -147,7 +147,7 @@ const triagem = f.flow({
147
147
  { id: 'aviso', do: 'notify', with: { recipient: 'owner', message: 'Lead: {{data.nome}} ({{data.empresa}})' } },
148
148
  { id: 'tchau', say: 'Um vendedor continua daqui.' },
149
149
  ],
150
- onEnd: 'end', // or 'stay' (repeat the last step) or 'reset' (first step, data kept)
150
+ onEnd: 'end', // or 'stay' (the last talk step answers every later message) or 'reset' (first step, data kept)
151
151
  });
152
152
  ```
153
153
 
@@ -206,7 +206,7 @@ Per-field wording lives on the field (`ask`); a step may override it (`ask: { no
206
206
  type Next = string /* step id or 'end' */ | { step: string; clear?: string[] } | { flow: string; input?: unknown };
207
207
  ```
208
208
 
209
- Branches stay on talk steps, judged while the step is asking: `{ when: '...', then }` for the model, `{ if: pred, then }` for code. A `wait` step takes `if` branches only, judged when the customer replies, and only when the step also has an `else`; a `when` branch on a wait never fires. There is no standalone AI-judged step: the model forks only where fresh customer text exists.
209
+ Branches stay on talk steps, judged while the step is asking: `{ when: '...', then }` for the model, `{ if: pred, then }` for code. A `wait` step takes `if` branches only, judged when the customer replies, and only when the step also has an `else`; `validateFlow` rejects a `when` branch on a wait, since nothing would ever judge it. There is no standalone AI-judged step: the model forks only where fresh customer text exists.
210
210
 
211
211
  ```ts fragment
212
212
  // ─── v3 ───
@@ -253,7 +253,10 @@ const f = falai().fields({ nome: { type: 'string' } });
253
253
 
254
254
  const concorrente = f.flow({
255
255
  id: 'concorrente', name: 'Lead falou de concorrente',
256
- on: [{ mention: ['o lead cita ou compara com um concorrente'], extract: { trecho: { type: 'string' } }, repeat: 'once' }],
256
+ on: [{
257
+ mention: ['o lead cita ou compara com um concorrente', '!o lead fala do nosso próprio produto'],
258
+ extract: { trecho: { type: 'string' } }, repeat: 'once',
259
+ }],
257
260
  steps: [
258
261
  { id: 'tag', do: 'add_tags', with: { tags: ['concorrente'] } },
259
262
  { id: 'avisa', do: 'notify', with: { recipient: 'owner', message: '{{data.nome}} falou de concorrente: "{{input.trecho}}"' } },
@@ -263,7 +266,7 @@ const concorrente = f.flow({
263
266
 
264
267
  | Signal facet | v4 |
265
268
  |---|---|
266
- | `when[]` with `!` exclusions | `mention: [...]`; write the exclusion into the phrase |
269
+ | `when[]` with `!` exclusions | `mention: [...]`, exclusions and all — a `!` phrase still rules the trigger out. Copy the list across unchanged |
267
270
  | `if` | trigger `if`; sees `input` after `extract` |
268
271
  | `extract` | trigger `extract` → `run.input` → `{{input.x}}`; never written to `data` |
269
272
  | `phase: 'pre'` + `halt` + `reply` | a `say` first step; another run's `say` silences the floor's reply that turn |
@@ -300,7 +303,7 @@ const retomar = f.flow({
300
303
  });
301
304
  ```
302
305
 
303
- - `wait: '3s'` (10 s or less, and the next step is a `say` or a talk step) becomes `afterMs` on that message in the same turn; every other wait parks the run (stops it until a wake) and puts `{ key, at }` in `schedule[]`. Enqueue the wake with `jobId = key` and call `turn({ wake: key })` when it fires. The framework never cancels a wake itself: a stale one is ignored (`changed: false`). A re-armed silence wake names the one it supersedes in `replaces`; removing that job is optional.
306
+ - `wait: '3s'` (10 s or less, and the next step is a `say` or a talk step) becomes `afterMs` on that message in the same turn; every other wait parks the run (stops it until a wake) and puts `{ key, at }` in `schedule[]`. Enqueue the wake with `encodeURIComponent(key)` as the job id (BullMQ refuses a `:` in a custom id) and call `turn({ wake: key })` when it fires. The framework never cancels a wake itself: a stale one is ignored (`changed: false`). A re-armed silence wake names the one it supersedes in `replaces`; removing that job is optional.
304
307
  - `on: [{ event: 'stage_entered', after: '1h' }]` starts a run when your code calls `turn({ event, payload, key })`. Declare events with `f.event<Payload>({ direction? })`: `inbound` counts as the customer speaking, `outbound` as the assistant.
305
308
  - `wait: { event: 'meeting_booked', upTo: '7d' }` parks until the event arrives.
306
309
  - Runs inside a session are concurrent; at most one is asking a question. A timer-started talk step suspends the current asker and hands the floor back when it is done.
@@ -346,7 +349,7 @@ interface Store<D> {
346
349
  }
347
350
  ```
348
351
 
349
- The seven adapters survive as `Store` implementations and take the same client you passed before: `MemoryStore`, `PostgresStore`, `PrismaStore`, `RedisStore`, `MongoStore`, `SQLiteStore`, `OpenSearchStore`. They persist the v4 blob and a version, nothing else; message repositories, `SessionRepository`, `status`, `currentFlow` / `currentStep` columns, `PersistenceManager`, `autoSave`, `schemaVersion` and `restoreSession` are gone. The framework never calls a store: you `load`, `turn`, `save`.
352
+ The seven adapters survive as `Store` implementations and take the same client you passed before: `MemoryStore`, `PostgresStore`, `PrismaStore`, `RedisStore`, `MongoStore`, `SQLiteStore`, `OpenSearchStore`. Each takes one options object with the client in it, so OpenSearch's becomes `new OpenSearchStore({ client, ...options })`. They persist the v4 blob and a version, nothing else; message repositories, `SessionRepository`, `status`, `currentFlow` / `currentStep` columns, `PersistenceManager`, `autoSave`, `schemaVersion` and `restoreSession` are gone. The framework never calls a store: you `load`, `turn`, `save`.
350
353
 
351
354
  **Use a fresh table.** The default names are the 3.x ones (`agent_sessions`, `agent:` prefix), so pass a new one (`tables.sessions` on Postgres, SQLite and Prisma, `collections.sessions` on Mongo, `indices.sessions` on OpenSearch, `keyPrefix` on Redis) or drop the old table first; `initialize()` (Postgres, SQLite, OpenSearch) only creates the table or index when it is missing, and does nothing while one of that name exists. A v4 store read against a live 3.x row fails loudly: Redis, Mongo, Prisma and OpenSearch throw `InvalidSessionError` (no `blob`), Postgres and SQLite fail on the missing `blob` column. Create the new table, then migrate rows on first load as §10 shows.
352
355
 
@@ -383,6 +386,7 @@ const session = migrateSession(rowBlob, {
383
386
  - `version` is 0: the session has no row in the v4 table yet, so your usual `store.save(session, session.version)` is the insert.
384
387
  - `pendingDirective` is dropped.
385
388
  - A blob that is neither v4 nor a recognisable 3.x state throws `InvalidSessionError`; a corrupt row can no longer become a fresh conversation silently.
389
+ - 3.x did not record when the assistant last spoke, so a lifted session has no `lastAssistantAt` and arms no silence follow-up until the assistant speaks again. At cutover, set `session.lastAssistantAt` and `session.lastUserAt` from your messages table, save, and enqueue `agent.pendingWakes({ session, context })`: a conversation that was quiet at cutover then gets its follow-up on time.
386
390
 
387
391
  Add a test that loads one real (anonymised) row per product and asserts the run's `stepId` and the carried claims.
388
392
 
@@ -407,13 +411,14 @@ Host actions, events and conditions are registered once on the agent and referen
407
411
  | `agent.dispatch`, `pendingDirective`, `Directive`, `flow.merge`, `flow.validate` | `then` / `else` on steps |
408
412
  | `Flow` class, `Step` class, `flow` namespace, `FlowOptions`, `StepOptions` | plain objects: `Flow`, `Step` |
409
413
  | `title`, `when`, `if`, `reentrant`, `requiredFields`, `optionalFields`, `onComplete`, flow `hooks` | `id` + `name`, `on[]`, `repeat`, `clearOnStart`, `onEnd`, `while` |
410
- | `requires`, `skip`, `auto`, `reply`, step `hooks`, `prepare`, `finalize` | known-field skipping, `maxAsks`, `do`, `if`, `wait`, `say` |
414
+ | `skip`, `auto`, `reply`, step `hooks`, `prepare`, `finalize` | known-field skipping, `maxAsks`, `do`, `wait`, `say` |
415
+ | `requires` | an `if` step: `{ if: { known: [...] }, then: '<step>', else: 'end' }`. Not known-field skipping — that answers "do I still need to ask?", while `requires` answers "may this step run at all?". They differ exactly when the customer never answers: `maxAsks` retires the field and the step proceeds with it unknown. |
411
416
  | `Signal`, `SignalContext`, `SignalFiring`, `signals`, `signalBatchSize`, `triggeredSignals` | `mention` flows, `repeat`, `claims` |
412
417
  | `ToolContext.updateContext / updateData / setField / dispatch`, `ToolResult.dataUpdate / contextUpdate / directive`, `ToolManager`, `ToolScope`, tool config helpers | `Tool.handler(args, ctx) → { value?, data? }` |
413
418
  | `PersistenceAdapter`, `SessionRepository`, `MessageRepository`, `PersistenceManager`, `SessionManager`, `restoreSession`, `createPersistedState`, `enterFlow`, `enterStep`, `completeCurrentFlow`, `mergeCollected` | `Store`, the seven `*Store` classes, `migrateSession` |
414
419
  | `SessionState`, `CollectedStateData`, `SessionData` | `Session` |
415
420
  | `AgentResponse.executedSteps / stoppedReason / endedFlows / appliedInstructions / isFlowComplete` | `TurnResult.outcomes / started / ended / skipped / messages / schedule / llmCalls` |
416
- | `Template` as a function, `TemplateContext`, `ConditionEvaluator`, `ConditionWhen`, `ConditionIf`, `!` exclusions | `Template = string` with `{{data.x}}` `{{context.x}}` `{{input.x}}`; `Pred` (function or JSON) |
421
+ | `Template` as a function, `TemplateContext`, `ConditionEvaluator`, `ConditionWhen`, `ConditionIf` | `Template = string` with `{{data.x}}` `{{context.x}}` `{{input.x}}`; `Pred` (function or JSON). **`!` exclusions are not gone** — a phrase opening with `!` still rules a trigger out. Copy the list across unchanged. |
417
422
  | `Term`, `terms` | put the glossary in `knowledgeBase` or an instruction |
418
423
  | `Instruction.enabled / tags / metadata` | filter before passing |
419
424
  | `promptCache`, `PromptSectionCache`, `PromptCacheConfig` | gone; every prompt is built per call. `compaction` stays and runs once per turn on the history you pass |
@@ -437,7 +442,7 @@ Then, in this order:
437
442
  1. Convert stored flows and signal rules to `FlowSpec` rows. Keep talk-step ids; give each migrated signal flow `id = signal key`.
438
443
  2. Register your actions, events and conditions on the agent.
439
444
  3. Replace the `respond` call site with load → `turn` → save + messages + schedules in one transaction.
440
- 4. Wire wakes (`jobId = key`, `turn({ wake })` at fire time) and host events.
445
+ 4. Wire wakes (job id `encodeURIComponent(key)`, `turn({ wake })` at fire time) and host events. At cutover, enqueue `agent.pendingWakes()` for every lifted session that was quiet.
441
446
  5. Put `migrateSession` in your deserializer and let it throw on garbage.
442
447
  6. Delete the automation engine, the follow-up sweep and the second composer.
443
448
 
@@ -116,7 +116,7 @@ f.action<const P extends ParamDefs>(def: {
116
116
 
117
117
  ### Behaviour
118
118
 
119
- - `with` is rendered before `run` sees it. `{{data.x}}`, `{{context.x}}` and `{{input.x}}` are replaced inside every string, at any depth. A path that resolves to nothing keeps its placeholder, so a typo stays visible.
119
+ - `with` is rendered before `run` sees it. `{{data.x}}`, `{{context.x}}` and `{{input.x}}` are replaced inside every string, at any depth. An unknown path keeps its placeholder, so a typo stays visible. A blank one drops out instead and the gap it left in the sentence closes — an empty string, or a path through a `null` (`{{context.lead.name}}` with no lead). A `null` at the end of a path is unknown, not blank.
120
120
  - `with` is checked when the agent is built, not on the turn that reaches the step. A missing required parameter, an unknown parameter, or a value outside `enum` throws `FlowConfigurationError`. So does a wrong type: `"3"` is not a number, because values are never coerced. A string that contains `{{` skips the `enum` check, because its value is only known at run time.
121
121
  - Actions run in the Run phase, by code, with zero model calls. They run while `silenced` too.
122
122
  - A `do` step whose action name is not registered throws at build. If the registry changed under a running agent, the step reports `code: 'action-failed'` with `detail: 'unknown action "notify"'`.
@@ -220,7 +220,7 @@ An event turn never spends an understand call. It costs one speak call (plus too
220
220
 
221
221
  1. **Direction.** `'inbound'` sets `lastUserAt` to now and resolves reply waits: every run parked on a timer `wait` that has an `else` resumes with `code: 'replied'` and follows a matching `if` branch's `then`, else `else`. `'outbound'` sets `lastAssistantAt` to now, which re-arms `silence` triggers at the end of the turn.
222
222
  2. **Waiting runs.** Every run parked on `wait: { event: name }` for this name resumes with `code: 'event-arrived'` and follows `then`. The first one to resume takes the floor for this turn.
223
- 3. **Triggers.** Every flow with `on: [{ event: name }]` goes through the start order: trigger `if`, `repeat` (default `'always'` for events), the hop cap, one live run per flow and anchor. With `after`, the run parks first (`code: 'awaiting-trigger'`, wake key `${runId}:start:${atMs}`) and enters its first step when the wake fires; `businessHours: true` snaps that time forward through the agent's `businessHours` function.
223
+ 3. **Triggers.** Every flow with `on: [{ event: name }]` goes through the start order: trigger `if`, `repeat` (default `'always'` for events), the hop cap, one live run per flow and anchor. With `after`, the run parks first (`code: 'awaiting-trigger'`, wake key `${runId}:start:${atMs}`) and enters its first step when the wake fires; `businessHours: true` snaps that time forward through the agent's `businessHours` function. With `businessHours: true` and no `after`, an event that arrives outside working hours parks the run the same way until the next working moment.
224
224
 
225
225
  `wait: { event, upTo }` in a step parks the run for at most `upTo` (default `'30d'`, from `src/core/Runner.ts`). If the event never comes, the line carries `code: 'no-event'` and the run follows `else`, or ends when there is none.
226
226