@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
@@ -7,8 +7,7 @@
7
7
  *
8
8
  * begin ─▶ understandRequest ─(Understand)─▶ decide ─▶ advance ─(Speak)─▶ settle ─▶ finish
9
9
  */
10
- import { cloneDeep } from "../utils/clone.js";
11
- import { parseDuration } from "../utils/duration.js";
10
+ import { isDuration, parseDuration } from "../utils/duration.js";
12
11
  import { OUTCOME_MESSAGES } from "../utils/outcomes.js";
13
12
  import { coerceField, DEFAULT_MAX_ASKS, extractMode, isKnown, pendingFields } from "../utils/schema.js";
14
13
  import { render, renderDeep } from "../utils/template.js";
@@ -64,6 +63,15 @@ function defaultRepeat(kind) {
64
63
  function talkKind(step) {
65
64
  return step.collect?.length ? "collect" : "prompt";
66
65
  }
66
+ /** Everything a flow collects: its own `collect`, then each talk step's, once each. */
67
+ function flowFields(flow) {
68
+ const fields = new Set(flow.collect);
69
+ for (const step of flow.steps)
70
+ if (isTalk(step))
71
+ for (const field of step.collect ?? [])
72
+ fields.add(field);
73
+ return [...fields];
74
+ }
67
75
  function kindOf(step) {
68
76
  if (!step)
69
77
  return "if";
@@ -100,16 +108,29 @@ export class Runner {
100
108
  }
101
109
  // ── Load + Ingest ─────────────────────────────────────────────────────
102
110
  begin(input) {
103
- const now = (this.options.clock ?? (() => new Date()))();
111
+ const now = this.now();
112
+ const turn = this.open(input, describe(input, now.toISOString()), now);
113
+ if (turn.what.kind === "wake" && !input.session) {
114
+ this.ignore(turn, "no-session", turn.what.key);
115
+ return turn;
116
+ }
117
+ turn.ingesting = true;
118
+ this.ingest(turn);
119
+ turn.ingesting = false;
120
+ return turn;
121
+ }
122
+ now() {
123
+ return (this.options.clock ?? (() => new Date()))();
124
+ }
125
+ open(input, what, now) {
104
126
  const nowIso = now.toISOString();
105
- const what = describe(input, nowIso);
106
127
  const silenced = typeof input.silenced === "string" ? input.silenced : input.silenced?.reason;
107
- const turn = {
128
+ return {
108
129
  input,
109
130
  what,
110
131
  kind: what.kind,
111
132
  triggerKey: what.kind === "message" ? (what.id ?? what.at) : what.key,
112
- session: input.session ? cloneDeep(input.session) : freshSession(input.sessionId),
133
+ session: input.session ? structuredClone(input.session) : freshSession(input.sessionId),
113
134
  original: input.session,
114
135
  now,
115
136
  nowIso,
@@ -117,6 +138,7 @@ export class Runner {
117
138
  context: input.context,
118
139
  silenced,
119
140
  understandAllowed: typeof input.silenced === "object" && input.silenced.understand === true,
141
+ skipSilenced: typeof input.silenced === "object" && input.silenced.skip === true,
120
142
  ignored: false,
121
143
  messages: [],
122
144
  schedule: [],
@@ -133,14 +155,14 @@ export class Runner {
133
155
  speakDone: false,
134
156
  queue: [],
135
157
  };
136
- if (what.kind === "wake" && !input.session) {
137
- this.ignore(turn, "no-session", what.key);
138
- return turn;
139
- }
140
- turn.ingesting = true;
141
- this.ingest(turn);
142
- turn.ingesting = false;
143
- return turn;
158
+ }
159
+ /** Every wake the session waits on: each parked run's own, then each silence flow's since the assistant last spoke. */
160
+ pendingWakes(input) {
161
+ // No input arrives, so nothing reads `what`: the inert wake shape only fills the field.
162
+ const turn = this.open({ ...input, sessionId: input.session.id }, { kind: "wake", key: "" }, this.now());
163
+ // `park` always sets both; the type leaves them optional, so a hand-built blob without them has no wake to give.
164
+ const parked = turn.session.runs.flatMap(({ status, waiting }) => status === "waiting" && waiting?.key && waiting.until ? [{ key: waiting.key, at: new Date(waiting.until) }] : []);
165
+ return [...parked, ...this.silenceWakes(turn)];
144
166
  }
145
167
  ingest(turn) {
146
168
  const { what, session } = turn;
@@ -221,6 +243,9 @@ export class Runner {
221
243
  const branch = step.branches?.find((b) => "if" in b && this.holds(b.if, turn, run));
222
244
  const target = branch ? branch.then : step.else;
223
245
  this.outcome(turn, run, { kind: "wait", status: "ok", code: "replied", next: nextLabel(target) });
246
+ // The reply is this run's while it moves, so a stay it reaches answers it.
247
+ if (!turn.floorFromIngest)
248
+ turn.floorRunId = run.id;
224
249
  this.follow(turn, run, flow, step, target);
225
250
  this.takeFloor(turn, run);
226
251
  }
@@ -260,8 +285,10 @@ export class Runner {
260
285
  /** `silence:<flowId>:<sessionId>:<lastAssistantAtMs>`, honoured only while the blob still shows that silence. */
261
286
  silenceWake(turn, key) {
262
287
  const rest = key.slice("silence:".length);
263
- const flowId = rest.slice(0, rest.indexOf(":"));
264
288
  const ms = Number(rest.slice(rest.lastIndexOf(":") + 1));
289
+ // A flow id may hold a ":" itself, so cut at the session id rather than at the first ":".
290
+ const cut = rest.lastIndexOf(`:${turn.session.id}:`);
291
+ const flowId = rest.slice(0, cut === -1 ? rest.indexOf(":") : cut);
265
292
  const { lastAssistantAt } = turn.session;
266
293
  const flow = this.flows.get(flowId);
267
294
  if (!flow) {
@@ -316,13 +343,9 @@ export class Runner {
316
343
  };
317
344
  if (opts.trigger?.if && !this.holds(opts.trigger.if, turn, run))
318
345
  return null;
319
- const heldAt = this.claimAt(turn, dedupeKey);
320
- if (heldAt !== undefined) {
321
- if (typeof repeat === "string")
322
- return skip("already-claimed");
323
- if (turn.now.getTime() - Date.parse(heldAt) < parseDuration(repeat.cooldown))
324
- return skip("cooldown");
325
- }
346
+ const blocked = this.repeatBlocks(turn, dedupeKey, repeat);
347
+ if (blocked)
348
+ return skip(blocked);
326
349
  if (hop >= MAX_HOP)
327
350
  return skip("hop-limit");
328
351
  const live = session.runs.find((r) => r.flowId === flow.id && r.anchor === anchor);
@@ -345,9 +368,11 @@ export class Runner {
345
368
  }
346
369
  session.runs.push(run);
347
370
  turn.started.push({ runId: run.id, flowId: flow.id, anchor, dedupeKey });
348
- const after = opts.trigger && "event" in opts.trigger ? opts.trigger.after : undefined;
349
- if (after) {
350
- const at = this.snap(turn, new Date(turn.now.getTime() + parseDuration(after)), opts.trigger && "event" in opts.trigger ? opts.trigger.businessHours : undefined);
371
+ const eventTrigger = opts.trigger && "event" in opts.trigger ? opts.trigger : undefined;
372
+ const after = eventTrigger?.after;
373
+ const at = this.snap(turn, new Date(turn.now.getTime() + (after ? parseDuration(after) : 0)), eventTrigger?.businessHours);
374
+ // With no `after`, only a closed hour parks the start: inside working hours the snap returns now.
375
+ if (after || at.getTime() > turn.now.getTime()) {
351
376
  this.park(turn, run, { kind: "timer", key: `${run.id}:start:${at.getTime()}` }, at);
352
377
  this.outcome(turn, run, { kind: "wait", status: "waiting", code: "awaiting-trigger", until: at.toISOString() });
353
378
  }
@@ -369,12 +394,16 @@ export class Runner {
369
394
  }
370
395
  repeatAllows(turn, flow, trigger, key) {
371
396
  const repeat = trigger.repeat ?? defaultRepeat(triggerKind(trigger));
372
- const heldAt = this.claimAt(turn, `${flow.id}:${this.anchorOf(turn, flow)}:${repeat === "always" ? key : ""}`);
397
+ return !this.repeatBlocks(turn, `${flow.id}:${this.anchorOf(turn, flow)}:${repeat === "always" ? key : ""}`, repeat);
398
+ }
399
+ /** Why the claim already on `dedupeKey` stops another start, or nothing when there is none or its cooldown has run out. */
400
+ repeatBlocks(turn, dedupeKey, repeat) {
401
+ const heldAt = this.claimAt(turn, dedupeKey);
373
402
  if (heldAt === undefined)
374
- return true;
403
+ return undefined;
375
404
  if (typeof repeat === "string")
376
- return false;
377
- return turn.now.getTime() - Date.parse(heldAt) >= parseDuration(repeat.cooldown);
405
+ return "already-claimed";
406
+ return turn.now.getTime() - Date.parse(heldAt) < parseDuration(repeat.cooldown) ? "cooldown" : undefined;
378
407
  }
379
408
  /** A run as it would be if started now, for trigger-level predicates. */
380
409
  draftRun(turn, flow, kind, key, payload) {
@@ -436,8 +465,7 @@ export class Runner {
436
465
  const floorRun = this.floorRun(turn);
437
466
  const floorFlow = floorRun && this.flows.get(floorRun.flowId);
438
467
  const eligible = this.eligibleMessageFlows(turn);
439
- // Exactly one eligible message flow and no floor: it starts without scoring (S9).
440
- const messageFlows = !floorRun && eligible.length === 1 ? [] : eligible;
468
+ const messageFlows = !floorRun && this.startsUnscored(turn, eligible) ? [] : eligible;
441
469
  const mentionFlows = this.mentionFlows()
442
470
  .filter(({ flow, trigger }) => trigger.mention.length > 0 && this.repeatAllows(turn, flow, trigger, turn.triggerKey))
443
471
  .map(({ flow }) => flow);
@@ -451,18 +479,28 @@ export class Runner {
451
479
  }
452
480
  const fields = {};
453
481
  const data = turn.session.data;
482
+ const harvestable = (field) => {
483
+ const def = this.options.fields[field];
484
+ return def !== undefined && !isKnown(data[field]) && extractMode(def) === "anywhere";
485
+ };
454
486
  for (const flow of [...(floorFlow ? [floorFlow] : []), ...eligible]) {
455
- for (const step of flow.steps) {
456
- if (!isTalk(step))
457
- continue;
458
- for (const field of step.collect ?? []) {
459
- const def = this.options.fields[field];
460
- if (def && !isKnown(data[field]) && extractMode(def) === "anywhere")
461
- fields[field] = def;
462
- }
463
- }
487
+ for (const field of flowFields(flow).filter(harvestable))
488
+ fields[field] = this.options.fields[field];
464
489
  }
465
- if (!messageFlows.length && !mentionFlows.length && !branches.length && !Object.keys(fields).length)
490
+ const judging = messageFlows.length > 0 || mentionFlows.length > 0 || branches.length > 0 || Object.keys(fields).length > 0;
491
+ // With nobody on the floor the catch-all may take this message, and an opening message often says the most. The
492
+ // fields its first step asks ride on that step's speak call, so alone they spend no call; a fixed question has no speak call.
493
+ // ponytail: "first step" is steps[0]; a catch-all that opens with a say or an if pays the call for that step's fields too.
494
+ const fallback = floorRun ? undefined : this.messageFlows(turn, (list) => list.length === 0)[0];
495
+ if (fallback) {
496
+ const first = fallback.steps[0];
497
+ const free = new Set(first && isTalk(first) && first.question === undefined ? first.collect : []);
498
+ const own = flowFields(fallback).filter(harvestable);
499
+ if (judging || own.some((field) => !free.has(field)))
500
+ for (const field of own)
501
+ fields[field] = this.options.fields[field];
502
+ }
503
+ if (!judging && !Object.keys(fields).length)
466
504
  return null;
467
505
  return {
468
506
  text: turn.what.text,
@@ -491,6 +529,13 @@ export class Runner {
491
529
  eligibleMessageFlows(turn) {
492
530
  return this.messageFlows(turn, (list) => list.length > 0);
493
531
  }
532
+ /**
533
+ * The lone eligible flow starts without a score only when a low score would have nowhere
534
+ * else to send the message: no catch-all passes and the idle speaker is silent (S9).
535
+ */
536
+ startsUnscored(turn, eligible) {
537
+ return eligible.length === 1 && this.options.idle === "silent" && this.messageFlows(turn, (list) => list.length === 0).length === 0;
538
+ }
494
539
  messageFlows(turn, accept) {
495
540
  const out = [];
496
541
  for (const flow of this.flows.values()) {
@@ -535,7 +580,7 @@ export class Runner {
535
580
  if (top && top.score >= current + ROUTE_STICKY && top.score >= ROUTE_MIN)
536
581
  route = top.flow;
537
582
  }
538
- else if (eligible.length === 1) {
583
+ else if (this.startsUnscored(turn, eligible)) {
539
584
  route = eligible[0];
540
585
  }
541
586
  else {
@@ -564,6 +609,9 @@ export class Runner {
564
609
  turn.floorRunId = run.id;
565
610
  }
566
611
  }
612
+ // Unless routing or Ingest moved it, the message is the asker's: written down, so a chain beside it cannot take it.
613
+ if (asker && !route && !turn.floorFromIngest)
614
+ turn.floorRunId = asker.id;
567
615
  for (const [field, raw] of Object.entries(understanding?.fields ?? {}))
568
616
  this.writeField(turn, field, raw);
569
617
  if (asker && turn.session.runs.includes(asker) && asker.status === "asking") {
@@ -574,7 +622,13 @@ export class Runner {
574
622
  }
575
623
  }
576
624
  fireBranch(turn, run, flow, step, understanding) {
577
- const hit = (branch, index) => "if" in branch ? this.holds(branch.if, turn, run) : understanding?.branches[`${run.id}/${step.id}/${index}`] === true;
625
+ // Staying, a run does not take an `if` branch back onto a path it has already run: the fact it tests is still true,
626
+ // so it would re-run that path on every message. A `when` branch is judged on each new message and always counts.
627
+ // ponytail: "already run" is the target's visit count, so an `if` restart (`{ step, clear }` to a visited step) never fires while staying; write it as a `when`.
628
+ const ran = (then) => typeof then === "string" ? then === "end" || (run.visits[then] ?? 0) > 0 : "step" in then && (run.visits[then.step] ?? 0) > 0;
629
+ const hit = (branch, index) => "if" in branch
630
+ ? !(run.staying && ran(branch.then)) && this.holds(branch.if, turn, run)
631
+ : understanding?.branches[`${run.id}/${step.id}/${index}`] === true;
578
632
  const index = (step.branches ?? []).findIndex(hit);
579
633
  if (index < 0)
580
634
  return;
@@ -604,10 +658,47 @@ export class Runner {
604
658
  async advance(turn) {
605
659
  if (turn.ignored)
606
660
  return null;
607
- this.resumeSuspended(turn);
661
+ this.resume(turn);
608
662
  turn.queue = [...turn.session.runs];
609
663
  await this.drain(turn);
610
- return this.speaker(turn);
664
+ // An asker that moved on without a word hands the message to the run it suspended, and that one to the next.
665
+ // Each pass takes a run off the suspended stack, so the stack's size bounds the loop.
666
+ for (let left = turn.session.runs.length; left > 0; left--) {
667
+ if (turn.what.kind !== "message" || turn.silenced !== undefined || turn.talk || turn.spokeBy.size > 0)
668
+ break;
669
+ const resumed = this.resume(turn);
670
+ if (!resumed)
671
+ break;
672
+ turn.queue.push(resumed);
673
+ await this.drain(turn);
674
+ }
675
+ const speaker = this.speaker(turn);
676
+ if (speaker && !("idle" in speaker) && this.askFixed(turn, speaker)) {
677
+ turn.talk = undefined;
678
+ return null;
679
+ }
680
+ return speaker;
681
+ }
682
+ /**
683
+ * Send the step's fixed question when this is its first ask: every field it collects still unknown, none asked yet,
684
+ * and the run not staying (a staying run answers the lead). The question counts as one ask of each field. False when
685
+ * the AI should phrase the ask instead.
686
+ */
687
+ askFixed(turn, { run, step, pending }) {
688
+ const { question, collect = [] } = step;
689
+ if (question === undefined || run.staying)
690
+ return false;
691
+ if (pending.length !== collect.length || pending.some((field) => (run.asked[field] ?? 0) > 0))
692
+ return false;
693
+ const key = this.stepKey(run, step);
694
+ turn.messages.push({ text: render(question, this.scope(turn, run)), kind: "verbatim", afterMs: turn.afterMs, key, runId: run.id, stepId: step.id });
695
+ turn.afterMs = 0;
696
+ turn.spokeBy.add(run.id);
697
+ turn.spoke = true;
698
+ for (const field of pending)
699
+ run.asked[field] = (run.asked[field] ?? 0) + 1;
700
+ this.outcome(turn, run, { kind: "collect", status: "ok", key, code: "asked-fixed", stepId: step.id });
701
+ return true;
611
702
  }
612
703
  /**
613
704
  * Everything Speak needs for this turn's one speaker. Instructions are agent,
@@ -630,12 +721,16 @@ export class Runner {
630
721
  const instructions = [...(this.options.instructions ?? []), ...own.instructions].filter((ins) => !ins.if || evaluate(ins.if, ctx, conditions));
631
722
  const all = this.options.tools ?? [];
632
723
  const allowed = own.tools;
724
+ // deferTalk leaves a `deferred` outcome as the step's last line; the retry wake re-runs the step without adding one.
725
+ const last = "idle" in talk ? undefined : talk.run.outcomes.at(-1);
726
+ const retry = !("idle" in talk) && last?.status === "deferred" && last.stepId === talk.step.id;
633
727
  return {
634
728
  talk,
635
- input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind },
729
+ input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind, ...(retry ? { retry } : {}) },
636
730
  context: turn.context,
637
731
  data: turn.session.data,
638
732
  history: this.historyOf(turn),
733
+ said: [...turn.messages],
639
734
  now: turn.now,
640
735
  instructions,
641
736
  tools: allowed ? all.filter((tool) => allowed.includes(tool.id)) : all,
@@ -678,6 +773,11 @@ export class Runner {
678
773
  }
679
774
  if (!this.premiseHolds(turn, run, flow))
680
775
  return;
776
+ // The flow was edited off 'stay' after this run finished its steps: its new `onEnd` decides now.
777
+ if (run.staying && flow.onEnd !== "stay") {
778
+ this.finishFlow(turn, run, flow);
779
+ return;
780
+ }
681
781
  const resuming = run.status === "asking";
682
782
  run.status = "running";
683
783
  if (run.stepId === null) {
@@ -720,6 +820,11 @@ export class Runner {
720
820
  this.endRun(turn, run, "skipped", "premise-changed");
721
821
  return ok;
722
822
  }
823
+ /** `silenced: { skip: true }`: the message this step would send is dropped, and the run takes `then` as if it had gone out. */
824
+ skipSilenced(turn, run, flow, step, kind) {
825
+ this.outcome(turn, run, { kind, status: "skipped", key: this.stepKey(run, step), code: "silenced", detail: turn.silenced, next: nextLabel(step.then) });
826
+ this.follow(turn, run, flow, step, step.then);
827
+ }
723
828
  async execute(turn, run, flow, step, resuming) {
724
829
  const data = turn.session.data;
725
830
  const key = this.stepKey(run, step);
@@ -728,7 +833,9 @@ export class Runner {
728
833
  let pending = [];
729
834
  if (step.collect?.length) {
730
835
  pending = pendingFields(step, data, run.asked);
731
- if (!pending.length) {
836
+ // A `stay` run answers even with nothing left to collect: answering is what it stays for. It only runs on a message
837
+ // (asking runs sit out wakes and events), and on a silenced one the check below keeps it asking.
838
+ if (!pending.length && !run.staying) {
732
839
  this.reportMaxAsks(turn, run, step);
733
840
  this.outcome(turn, run, resuming
734
841
  ? { kind, status: "ok", key, next: nextLabel(step.then) }
@@ -740,6 +847,8 @@ export class Runner {
740
847
  if (turn.silenced !== undefined) {
741
848
  if (resuming)
742
849
  run.status = "asking"; // the gate is closed; it speaks when the gate opens
850
+ else if (turn.skipSilenced)
851
+ this.skipSilenced(turn, run, flow, step, kind);
743
852
  else
744
853
  this.endRun(turn, run, "skipped", "silenced", kind, turn.silenced);
745
854
  return;
@@ -751,13 +860,19 @@ export class Runner {
751
860
  }
752
861
  }
753
862
  run.status = "asking";
863
+ // Reached after Speak (settle's drain), a talk step waits for the next message; a fixed question costs no call, so it goes out now, as a say would.
754
864
  if (!turn.speakDone)
755
865
  turn.talk = { run, flow, step, pending };
866
+ else
867
+ this.askFixed(turn, { run, flow, step, pending });
756
868
  return;
757
869
  }
758
870
  if (isSay(step)) {
759
871
  if (turn.silenced !== undefined) {
760
- this.endRun(turn, run, "skipped", "silenced", "say", turn.silenced);
872
+ if (turn.skipSilenced)
873
+ this.skipSilenced(turn, run, flow, step, "say");
874
+ else
875
+ this.endRun(turn, run, "skipped", "silenced", "say", turn.silenced);
761
876
  return;
762
877
  }
763
878
  if (step.once) {
@@ -804,6 +919,13 @@ export class Runner {
804
919
  result = { failed: error instanceof Error ? error.message : String(error) };
805
920
  }
806
921
  }
922
+ // The handler already ran, so a defer that cannot be parsed must not throw
923
+ // the turn: every replay would repeat the side effect and fail again.
924
+ if ("defer" in result && !isDuration(String(result.defer))) {
925
+ result = {
926
+ failed: `action "${step.do}" asked to defer by "${String(result.defer)}", which is not a duration. Use a value like "2m" or "1h".`,
927
+ };
928
+ }
807
929
  if ("ok" in result) {
808
930
  if (result.spoke) {
809
931
  turn.spokeBy.add(run.id);
@@ -895,18 +1017,21 @@ export class Runner {
895
1017
  }
896
1018
  if ("step" in target) {
897
1019
  const data = turn.session.data;
898
- for (const field of target.clear ?? [])
1020
+ // Forgetting a field means asking for it from scratch: its ask count goes too, so a fixed question goes out again.
1021
+ for (const field of target.clear ?? []) {
899
1022
  delete data[field];
1023
+ delete run.asked[field];
1024
+ }
900
1025
  this.jump(turn, run, flow, target.step);
901
1026
  return;
902
1027
  }
903
1028
  const childId = render(target.flow, this.scope(turn, run));
904
1029
  const key = this.stepKey(run, step);
905
1030
  this.endRun(turn, run, "flow");
906
- this.chain(turn, childId, key, target.input ?? run.input, run.hop + 1);
1031
+ this.chain(turn, run, childId, key, target.input ?? run.input, run.hop + 1);
907
1032
  }
908
- /** Start a child flow that holds the floor and moves in this same phase. */
909
- chain(turn, flowId, key, input, hop, keepData = false) {
1033
+ /** Start a child flow that moves in this same phase. It inherits the floor when its parent held it, or when nobody did. */
1034
+ chain(turn, parent, flowId, key, input, hop, keepData = false) {
910
1035
  const child = this.flows.get(flowId);
911
1036
  if (!child) {
912
1037
  turn.skipped.push({ flowId, anchor: turn.session.id, triggerKey: key, code: "flow-gone", message: OUTCOME_MESSAGES["flow-gone"] });
@@ -915,9 +1040,11 @@ export class Runner {
915
1040
  const run = this.startRun(turn, child, "flow", key, { payload: input, hop, keepData });
916
1041
  if (!run)
917
1042
  return;
918
- turn.floorRunId = run.id;
919
- if (turn.ingesting)
920
- turn.floorFromIngest = true;
1043
+ if (turn.floorRunId === undefined || turn.floorRunId === parent.id) {
1044
+ turn.floorRunId = run.id;
1045
+ if (turn.ingesting)
1046
+ turn.floorFromIngest = true;
1047
+ }
921
1048
  turn.queue.push(run);
922
1049
  }
923
1050
  jump(turn, run, flow, stepId) {
@@ -932,14 +1059,54 @@ export class Runner {
932
1059
  run.visits[stepId] = (run.visits[stepId] ?? 0) + 1;
933
1060
  run.status = "running";
934
1061
  delete run.waiting;
1062
+ delete run.staying;
1063
+ }
1064
+ /** `onEnd: 'stay'`: the run sits on its last talk step and answers every message from there, each answer a new visit and so a new key. */
1065
+ stayAt(run, step) {
1066
+ this.enter(run, step.id);
1067
+ run.staying = true;
1068
+ run.status = "asking";
1069
+ }
1070
+ /**
1071
+ * Where `stay` answers from: the talk step this run last took, so a branched flow stays on its own path. The flow's last talk step when the log names none.
1072
+ * ponytail: read back from `run.outcomes`, which keeps 50 lines; a tail of 50+ lines after the talk step (a long polling loop) loses it and the run
1073
+ * stays on the flow's last talk step. Upgrade: record the talk step on the run when it talks.
1074
+ */
1075
+ stayStep(run, flow) {
1076
+ for (let i = run.outcomes.length - 1; i >= 0; i--) {
1077
+ const { kind, stepId } = run.outcomes[i];
1078
+ const step = kind === "prompt" || kind === "collect" ? this.stepOf(flow, stepId ?? null) : undefined;
1079
+ if (step && isTalk(step))
1080
+ return step;
1081
+ }
1082
+ return [...flow.steps].reverse().find(isTalk);
935
1083
  }
936
1084
  finishFlow(turn, run, flow) {
937
1085
  const onEnd = flow.onEnd ?? "end";
938
1086
  const last = flow.steps[flow.steps.length - 1];
939
- if (onEnd === "stay" && last) {
940
- // "repete o último passo": the run stays live at its last step, re-entered so the repetition mints a new key, and runs it on the next message.
941
- this.enter(run, last.id);
942
- run.status = "asking";
1087
+ const stay = onEnd === "stay" ? this.stayStep(run, flow) : undefined;
1088
+ if (stay) {
1089
+ // The steps after it ran once, on the way here; staying, it only answers.
1090
+ this.stayAt(run, stay);
1091
+ // One asker at a time. A run the lead's message went to holds the conversation and suspends the other asker;
1092
+ // any other run waits behind that asker and resumes when it is done.
1093
+ const others = turn.session.runs.filter((r) => r !== run && r.status === "asking");
1094
+ if (others.length && !(turn.what.kind === "message" && turn.floorRunId === run.id)) {
1095
+ run.status = "suspended";
1096
+ run.suspendedAt = turn.nowIso;
1097
+ return;
1098
+ }
1099
+ for (const other of others) {
1100
+ other.status = "suspended";
1101
+ other.suspendedAt = turn.nowIso;
1102
+ if (turn.talk?.run === other)
1103
+ turn.talk = undefined;
1104
+ }
1105
+ // A message nothing has answered yet gets its answer now. Reached in Ingest, the run stays asking: Decide judges its
1106
+ // branches as the asker's, and it answers when it moves.
1107
+ if (!turn.ingesting && turn.what.kind === "message" && turn.silenced === undefined && !turn.speakDone && turn.spokeBy.size === 0) {
1108
+ run.status = "running";
1109
+ }
943
1110
  return;
944
1111
  }
945
1112
  if (onEnd === "reset" && last) {
@@ -947,7 +1114,7 @@ export class Runner {
947
1114
  // It is a chain into itself, so it costs a hop: a code-only flow that resets forever stops at the hop cap instead of spinning.
948
1115
  const key = this.stepKey(run, last);
949
1116
  this.endRun(turn, run, "reset");
950
- this.chain(turn, flow.id, key, run.input, run.hop + 1, true);
1117
+ this.chain(turn, run, flow.id, key, run.input, run.hop + 1, true);
951
1118
  return;
952
1119
  }
953
1120
  this.endRun(turn, run, "end");
@@ -969,10 +1136,21 @@ export class Runner {
969
1136
  run.waiting = { ...waiting, until: at.toISOString(), setAt: turn.nowIso };
970
1137
  turn.schedule.push({ key: waiting.key, at });
971
1138
  }
1139
+ /** `resumeSuspended`, and on a message the resumed run's `if` branches are judged before it speaks, as the asker's are in Decide. */
1140
+ resume(turn) {
1141
+ const run = this.resumeSuspended(turn);
1142
+ if (run && turn.what.kind === "message") {
1143
+ const flow = this.flows.get(run.flowId);
1144
+ const step = flow && this.stepOf(flow, run.stepId);
1145
+ if (flow && step && isTalk(step))
1146
+ this.fireBranch(turn, run, flow, step, null);
1147
+ }
1148
+ return run;
1149
+ }
972
1150
  /** When nobody asks, the most recently suspended run returns to asking. */
973
1151
  resumeSuspended(turn) {
974
1152
  if (this.asker(turn))
975
- return;
1153
+ return undefined;
976
1154
  const suspendedAt = (r) => r.suspendedAt ?? r.startedAt;
977
1155
  const next = turn.session.runs
978
1156
  .filter((r) => r.status === "suspended")
@@ -981,6 +1159,7 @@ export class Runner {
981
1159
  next.status = "asking";
982
1160
  delete next.suspendedAt;
983
1161
  }
1162
+ return next;
984
1163
  }
985
1164
  // ── Settle: the one applier after Speak (design §4.7) ─────────────────
986
1165
  async settle(turn, outcome) {
@@ -1035,16 +1214,24 @@ export class Runner {
1035
1214
  this.outcome(turn, run, { kind: talkKind(step), status: "ok", key, llmCalls: spoken.llmCalls, stepId: step.id });
1036
1215
  if (!turn.session.runs.includes(run))
1037
1216
  return;
1038
- if (step.collect?.length) {
1039
- const pending = pendingFields(step, turn.session.data, run.asked);
1040
- if (pending.length) {
1041
- for (const field of pending)
1042
- run.asked[field] = (run.asked[field] ?? 0) + 1;
1043
- run.status = "asking";
1044
- return;
1217
+ const pending = step.collect?.length ? pendingFields(step, turn.session.data, run.asked) : [];
1218
+ for (const field of pending)
1219
+ run.asked[field] = (run.asked[field] ?? 0) + 1;
1220
+ if (run.staying) {
1221
+ // Each answer is a new visit, so a new key, even while a field is still pending; that field's max-asks is reported once, when it gets there.
1222
+ const maxAsks = step.maxAsks ?? DEFAULT_MAX_ASKS;
1223
+ for (const field of pending) {
1224
+ if (run.asked[field] === maxAsks)
1225
+ this.outcome(turn, run, { kind: "collect", status: "skipped", key, code: "max-asks", detail: field });
1045
1226
  }
1046
- this.reportMaxAsks(turn, run, step);
1227
+ this.stayAt(run, step);
1228
+ return;
1229
+ }
1230
+ if (pending.length) {
1231
+ run.status = "asking";
1232
+ return;
1047
1233
  }
1234
+ this.reportMaxAsks(turn, run, step);
1048
1235
  run.status = "running";
1049
1236
  this.follow(turn, run, flow, step, step.then);
1050
1237
  if (turn.session.runs.includes(run) && run.status === "running")
@@ -1081,16 +1268,23 @@ export class Runner {
1081
1268
  const reset = deferred.resetAtMs;
1082
1269
  return new Date(reset !== undefined && reset > ladder ? reset : ladder);
1083
1270
  }
1084
- /** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
1271
+ /** The assistant spoke this turn: its silence wakes replace the ones its last words set. */
1085
1272
  armSilence(turn) {
1273
+ const previous = turn.original?.lastAssistantAt;
1274
+ if (turn.session.lastAssistantAt === previous)
1275
+ return;
1276
+ turn.schedule.push(...this.silenceWakes(turn, previous));
1277
+ }
1278
+ /** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
1279
+ silenceWakes(turn, previous) {
1086
1280
  const { session } = turn;
1087
1281
  const { lastAssistantAt, lastUserAt } = session;
1088
- if (!lastAssistantAt || lastAssistantAt === turn.original?.lastAssistantAt)
1089
- return;
1282
+ if (!lastAssistantAt)
1283
+ return [];
1090
1284
  if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt))
1091
- return;
1285
+ return [];
1092
1286
  const ms = Date.parse(lastAssistantAt);
1093
- const previous = turn.original?.lastAssistantAt;
1287
+ const wakes = [];
1094
1288
  for (const flow of this.flows.values()) {
1095
1289
  const trigger = (flow.on ?? []).find((t) => "silence" in t);
1096
1290
  if (!trigger)
@@ -1101,12 +1295,13 @@ export class Runner {
1101
1295
  if (!this.repeatAllows(turn, flow, trigger, key))
1102
1296
  continue;
1103
1297
  const at = this.snap(turn, new Date(ms + parseDuration(trigger.silence)), trigger.businessHours);
1104
- turn.schedule.push({
1298
+ wakes.push({
1105
1299
  key: `silence:${flow.id}:${session.id}:${ms}`,
1106
1300
  at,
1107
1301
  ...(previous ? { replaces: `silence:${flow.id}:${session.id}:${Date.parse(previous)}` } : {}),
1108
1302
  });
1109
1303
  }
1304
+ return wakes;
1110
1305
  }
1111
1306
  // ── Return (design §4.8) ──────────────────────────────────────────────
1112
1307
  finish(turn) {