@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
@@ -8,13 +8,12 @@
8
8
  * begin ─▶ understandRequest ─(Understand)─▶ decide ─▶ advance ─(Speak)─▶ settle ─▶ finish
9
9
  */
10
10
 
11
- import type { AgentOptions, EndReason, TurnInput, TurnResult } from "../types/agent.js";
11
+ import type { AgentOptions, EndReason, PendingWakesInput, ScheduleEntry, TurnBase, TurnInput, TurnResult } from "../types/agent.js";
12
12
  import type { TokenUsage } from "../types/ai.js";
13
13
  import type { ActionResult, Branch, DoStep, Duration, Flow, IfStep, Next, Pred, PredCtx, Repeat, SayStep, Step, StepBase, TalkStep, Trigger, WaitEventStep, WaitStep } from "../types/flow.js";
14
14
  import type { History } from "../types/history.js";
15
15
  import type { Run, Session, StepOutcome, StepOutcomeCode, StepOutcomeKind, TriggerKind } from "../types/session.js";
16
- import { cloneDeep } from "../utils/clone.js";
17
- import { parseDuration } from "../utils/duration.js";
16
+ import { isDuration, parseDuration } from "../utils/duration.js";
18
17
  import { OUTCOME_MESSAGES } from "../utils/outcomes.js";
19
18
  import { coerceField, DEFAULT_MAX_ASKS, extractMode, isKnown, pendingFields } from "../utils/schema.js";
20
19
  import { render, renderDeep } from "../utils/template.js";
@@ -49,7 +48,7 @@ export type What =
49
48
 
50
49
  /** The mutable per-turn state. `session` is a deep copy; the input is never touched. */
51
50
  export interface Turn<C = unknown, D = unknown> {
52
- readonly input: TurnInput<C, D>;
51
+ readonly input: TurnBase<C, D>;
53
52
  readonly what: What;
54
53
  readonly kind: InputKind;
55
54
  /** Message id, wake key, event key or start key: the trigger key of runs started this turn. */
@@ -62,6 +61,8 @@ export interface Turn<C = unknown, D = unknown> {
62
61
  readonly silenced?: string;
63
62
  /** The host gate allows the understand call even though the assistant cannot speak. */
64
63
  readonly understandAllowed: boolean;
64
+ /** The host gate only stops messages: a talk or `say` step is skipped and the run goes on. */
65
+ readonly skipSilenced: boolean;
65
66
  /** Set by a short-circuit: the input is a no-op and nothing is saved. */
66
67
  ignored: boolean;
67
68
  messages: TurnResult<D>["messages"];
@@ -137,6 +138,13 @@ function talkKind(step: { collect?: readonly string[] }): StepOutcomeKind {
137
138
  return step.collect?.length ? "collect" : "prompt";
138
139
  }
139
140
 
141
+ /** Everything a flow collects: its own `collect`, then each talk step's, once each. */
142
+ function flowFields<C, D>(flow: Flow<C, D>): string[] {
143
+ const fields = new Set<string>(flow.collect);
144
+ for (const step of flow.steps) if (isTalk(step)) for (const field of step.collect ?? []) fields.add(field);
145
+ return [...fields];
146
+ }
147
+
140
148
  function kindOf<C, D>(step: Step<C, D> | undefined): StepOutcomeKind {
141
149
  if (!step) return "if";
142
150
  if (isTalk(step)) return talkKind(step);
@@ -171,16 +179,31 @@ export class Runner<C = unknown, D = unknown> {
171
179
  // ── Load + Ingest ─────────────────────────────────────────────────────
172
180
 
173
181
  begin(input: TurnInput<C, D>): Turn<C, D> {
174
- const now = (this.options.clock ?? (() => new Date()))();
182
+ const now = this.now();
183
+ const turn = this.open(input, describe(input, now.toISOString()), now);
184
+ if (turn.what.kind === "wake" && !input.session) {
185
+ this.ignore(turn, "no-session", turn.what.key);
186
+ return turn;
187
+ }
188
+ turn.ingesting = true;
189
+ this.ingest(turn);
190
+ turn.ingesting = false;
191
+ return turn;
192
+ }
193
+
194
+ private now(): Date {
195
+ return (this.options.clock ?? (() => new Date()))();
196
+ }
197
+
198
+ private open(input: TurnBase<C, D>, what: What, now: Date): Turn<C, D> {
175
199
  const nowIso = now.toISOString();
176
- const what = describe(input, nowIso);
177
200
  const silenced = typeof input.silenced === "string" ? input.silenced : input.silenced?.reason;
178
- const turn: Turn<C, D> = {
201
+ return {
179
202
  input,
180
203
  what,
181
204
  kind: what.kind,
182
205
  triggerKey: what.kind === "message" ? (what.id ?? what.at) : what.key,
183
- session: input.session ? cloneDeep(input.session) : freshSession<D>(input.sessionId),
206
+ session: input.session ? structuredClone(input.session) : freshSession<D>(input.sessionId),
184
207
  original: input.session,
185
208
  now,
186
209
  nowIso,
@@ -188,6 +211,7 @@ export class Runner<C = unknown, D = unknown> {
188
211
  context: input.context as C,
189
212
  silenced,
190
213
  understandAllowed: typeof input.silenced === "object" && input.silenced.understand === true,
214
+ skipSilenced: typeof input.silenced === "object" && input.silenced.skip === true,
191
215
  ignored: false,
192
216
  messages: [],
193
217
  schedule: [],
@@ -204,14 +228,17 @@ export class Runner<C = unknown, D = unknown> {
204
228
  speakDone: false,
205
229
  queue: [],
206
230
  };
207
- if (what.kind === "wake" && !input.session) {
208
- this.ignore(turn, "no-session", what.key);
209
- return turn;
210
- }
211
- turn.ingesting = true;
212
- this.ingest(turn);
213
- turn.ingesting = false;
214
- return turn;
231
+ }
232
+
233
+ /** Every wake the session waits on: each parked run's own, then each silence flow's since the assistant last spoke. */
234
+ pendingWakes(input: PendingWakesInput<C, D>): ScheduleEntry[] {
235
+ // No input arrives, so nothing reads `what`: the inert wake shape only fills the field.
236
+ const turn = this.open({ ...input, sessionId: input.session.id }, { kind: "wake", key: "" }, this.now());
237
+ // `park` always sets both; the type leaves them optional, so a hand-built blob without them has no wake to give.
238
+ const parked = turn.session.runs.flatMap(({ status, waiting }) =>
239
+ status === "waiting" && waiting?.key && waiting.until ? [{ key: waiting.key, at: new Date(waiting.until) }] : [],
240
+ );
241
+ return [...parked, ...this.silenceWakes(turn)];
215
242
  }
216
243
 
217
244
  private ingest(turn: Turn<C, D>): void {
@@ -285,6 +312,8 @@ export class Runner<C = unknown, D = unknown> {
285
312
  const branch = step.branches?.find((b) => "if" in b && this.holds(b.if, turn, run));
286
313
  const target = branch ? branch.then : step.else;
287
314
  this.outcome(turn, run, { kind: "wait", status: "ok", code: "replied", next: nextLabel(target) });
315
+ // The reply is this run's while it moves, so a stay it reaches answers it.
316
+ if (!turn.floorFromIngest) turn.floorRunId = run.id;
288
317
  this.follow(turn, run, flow, step, target);
289
318
  this.takeFloor(turn, run);
290
319
  }
@@ -323,8 +352,10 @@ export class Runner<C = unknown, D = unknown> {
323
352
  /** `silence:<flowId>:<sessionId>:<lastAssistantAtMs>`, honoured only while the blob still shows that silence. */
324
353
  private silenceWake(turn: Turn<C, D>, key: string): void {
325
354
  const rest = key.slice("silence:".length);
326
- const flowId = rest.slice(0, rest.indexOf(":"));
327
355
  const ms = Number(rest.slice(rest.lastIndexOf(":") + 1));
356
+ // A flow id may hold a ":" itself, so cut at the session id rather than at the first ":".
357
+ const cut = rest.lastIndexOf(`:${turn.session.id}:`);
358
+ const flowId = rest.slice(0, cut === -1 ? rest.indexOf(":") : cut);
328
359
  const { lastAssistantAt } = turn.session;
329
360
  const flow = this.flows.get(flowId);
330
361
  if (!flow) {
@@ -386,11 +417,8 @@ export class Runner<C = unknown, D = unknown> {
386
417
  return null;
387
418
  };
388
419
  if (opts.trigger?.if && !this.holds(opts.trigger.if, turn, run)) return null;
389
- const heldAt = this.claimAt(turn, dedupeKey);
390
- if (heldAt !== undefined) {
391
- if (typeof repeat === "string") return skip("already-claimed");
392
- if (turn.now.getTime() - Date.parse(heldAt) < parseDuration(repeat.cooldown)) return skip("cooldown");
393
- }
420
+ const blocked = this.repeatBlocks(turn, dedupeKey, repeat);
421
+ if (blocked) return skip(blocked);
394
422
  if (hop >= MAX_HOP) return skip("hop-limit");
395
423
  const live = session.runs.find((r) => r.flowId === flow.id && r.anchor === anchor);
396
424
  if (live) {
@@ -407,9 +435,11 @@ export class Runner<C = unknown, D = unknown> {
407
435
  }
408
436
  session.runs.push(run);
409
437
  turn.started.push({ runId: run.id, flowId: flow.id, anchor, dedupeKey });
410
- const after = opts.trigger && "event" in opts.trigger ? opts.trigger.after : undefined;
411
- if (after) {
412
- const at = this.snap(turn, new Date(turn.now.getTime() + parseDuration(after)), opts.trigger && "event" in opts.trigger ? opts.trigger.businessHours : undefined);
438
+ const eventTrigger = opts.trigger && "event" in opts.trigger ? opts.trigger : undefined;
439
+ const after = eventTrigger?.after;
440
+ const at = this.snap(turn, new Date(turn.now.getTime() + (after ? parseDuration(after) : 0)), eventTrigger?.businessHours);
441
+ // With no `after`, only a closed hour parks the start: inside working hours the snap returns now.
442
+ if (after || at.getTime() > turn.now.getTime()) {
413
443
  this.park(turn, run, { kind: "timer", key: `${run.id}:start:${at.getTime()}` }, at);
414
444
  this.outcome(turn, run, { kind: "wait", status: "waiting", code: "awaiting-trigger", until: at.toISOString() });
415
445
  }
@@ -433,10 +463,15 @@ export class Runner<C = unknown, D = unknown> {
433
463
 
434
464
  private repeatAllows(turn: Turn<C, D>, flow: Flow<C, D>, trigger: Trigger<C, D>, key: string): boolean {
435
465
  const repeat = trigger.repeat ?? defaultRepeat(triggerKind(trigger));
436
- const heldAt = this.claimAt(turn, `${flow.id}:${this.anchorOf(turn, flow)}:${repeat === "always" ? key : ""}`);
437
- if (heldAt === undefined) return true;
438
- if (typeof repeat === "string") return false;
439
- return turn.now.getTime() - Date.parse(heldAt) >= parseDuration(repeat.cooldown);
466
+ return !this.repeatBlocks(turn, `${flow.id}:${this.anchorOf(turn, flow)}:${repeat === "always" ? key : ""}`, repeat);
467
+ }
468
+
469
+ /** Why the claim already on `dedupeKey` stops another start, or nothing when there is none or its cooldown has run out. */
470
+ private repeatBlocks(turn: Turn<C, D>, dedupeKey: string, repeat: Repeat): "already-claimed" | "cooldown" | undefined {
471
+ const heldAt = this.claimAt(turn, dedupeKey);
472
+ if (heldAt === undefined) return undefined;
473
+ if (typeof repeat === "string") return "already-claimed";
474
+ return turn.now.getTime() - Date.parse(heldAt) < parseDuration(repeat.cooldown) ? "cooldown" : undefined;
440
475
  }
441
476
 
442
477
  /** A run as it would be if started now, for trigger-level predicates. */
@@ -508,8 +543,7 @@ export class Runner<C = unknown, D = unknown> {
508
543
  const floorRun = this.floorRun(turn);
509
544
  const floorFlow = floorRun && this.flows.get(floorRun.flowId);
510
545
  const eligible = this.eligibleMessageFlows(turn);
511
- // Exactly one eligible message flow and no floor: it starts without scoring (S9).
512
- const messageFlows = !floorRun && eligible.length === 1 ? [] : eligible;
546
+ const messageFlows = !floorRun && this.startsUnscored(turn, eligible) ? [] : eligible;
513
547
  const mentionFlows = this.mentionFlows()
514
548
  .filter(({ flow, trigger }) => trigger.mention.length > 0 && this.repeatAllows(turn, flow, trigger, turn.triggerKey))
515
549
  .map(({ flow }) => flow);
@@ -522,16 +556,25 @@ export class Runner<C = unknown, D = unknown> {
522
556
  }
523
557
  const fields: UnderstandRequest<C, D>["fields"] = {};
524
558
  const data: Record<string, unknown> = turn.session.data;
559
+ const harvestable = (field: string): boolean => {
560
+ const def = this.options.fields[field];
561
+ return def !== undefined && !isKnown(data[field]) && extractMode(def) === "anywhere";
562
+ };
525
563
  for (const flow of [...(floorFlow ? [floorFlow] : []), ...eligible]) {
526
- for (const step of flow.steps) {
527
- if (!isTalk(step)) continue;
528
- for (const field of step.collect ?? []) {
529
- const def = this.options.fields[field];
530
- if (def && !isKnown(data[field]) && extractMode(def) === "anywhere") fields[field] = def;
531
- }
532
- }
564
+ for (const field of flowFields(flow).filter(harvestable)) fields[field] = this.options.fields[field];
533
565
  }
534
- if (!messageFlows.length && !mentionFlows.length && !branches.length && !Object.keys(fields).length) return null;
566
+ const judging = messageFlows.length > 0 || mentionFlows.length > 0 || branches.length > 0 || Object.keys(fields).length > 0;
567
+ // With nobody on the floor the catch-all may take this message, and an opening message often says the most. The
568
+ // 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.
569
+ // 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.
570
+ const fallback = floorRun ? undefined : this.messageFlows(turn, (list) => list.length === 0)[0];
571
+ if (fallback) {
572
+ const first = fallback.steps[0];
573
+ const free = new Set<string>(first && isTalk(first) && first.question === undefined ? first.collect : []);
574
+ const own = flowFields(fallback).filter(harvestable);
575
+ if (judging || own.some((field) => !free.has(field))) for (const field of own) fields[field] = this.options.fields[field];
576
+ }
577
+ if (!judging && !Object.keys(fields).length) return null;
535
578
  return {
536
579
  text: turn.what.text,
537
580
  history: this.historyOf(turn),
@@ -562,6 +605,14 @@ export class Runner<C = unknown, D = unknown> {
562
605
  return this.messageFlows(turn, (list) => list.length > 0);
563
606
  }
564
607
 
608
+ /**
609
+ * The lone eligible flow starts without a score only when a low score would have nowhere
610
+ * else to send the message: no catch-all passes and the idle speaker is silent (S9).
611
+ */
612
+ private startsUnscored(turn: Turn<C, D>, eligible: Flow<C, D>[]): boolean {
613
+ return eligible.length === 1 && this.options.idle === "silent" && this.messageFlows(turn, (list) => list.length === 0).length === 0;
614
+ }
615
+
565
616
  private messageFlows(turn: Turn<C, D>, accept: (list: string[]) => boolean): Flow<C, D>[] {
566
617
  const out: Flow<C, D>[] = [];
567
618
  for (const flow of this.flows.values()) {
@@ -605,7 +656,7 @@ export class Runner<C = unknown, D = unknown> {
605
656
  const current = scores[asker.flowId] ?? 0;
606
657
  const top = best(eligible.filter((f) => f.id !== asker.flowId));
607
658
  if (top && top.score >= current + ROUTE_STICKY && top.score >= ROUTE_MIN) route = top.flow;
608
- } else if (eligible.length === 1) {
659
+ } else if (this.startsUnscored(turn, eligible)) {
609
660
  route = eligible[0];
610
661
  } else {
611
662
  const top = best(eligible);
@@ -632,6 +683,9 @@ export class Runner<C = unknown, D = unknown> {
632
683
  }
633
684
  }
634
685
 
686
+ // Unless routing or Ingest moved it, the message is the asker's: written down, so a chain beside it cannot take it.
687
+ if (asker && !route && !turn.floorFromIngest) turn.floorRunId = asker.id;
688
+
635
689
  for (const [field, raw] of Object.entries(understanding?.fields ?? {})) this.writeField(turn, field, raw);
636
690
 
637
691
  if (asker && turn.session.runs.includes(asker) && asker.status === "asking") {
@@ -642,8 +696,15 @@ export class Runner<C = unknown, D = unknown> {
642
696
  }
643
697
 
644
698
  private fireBranch(turn: Turn<C, D>, run: Run, flow: Flow<C, D>, step: TalkOf<C, D>, understanding: Understanding | null): void {
699
+ // Staying, a run does not take an `if` branch back onto a path it has already run: the fact it tests is still true,
700
+ // so it would re-run that path on every message. A `when` branch is judged on each new message and always counts.
701
+ // 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`.
702
+ const ran = (then: Next<D>): boolean =>
703
+ typeof then === "string" ? then === "end" || (run.visits[then] ?? 0) > 0 : "step" in then && (run.visits[then.step] ?? 0) > 0;
645
704
  const hit = (branch: Branch<C, D>, index: number): boolean =>
646
- "if" in branch ? this.holds(branch.if, turn, run) : understanding?.branches[`${run.id}/${step.id}/${index}`] === true;
705
+ "if" in branch
706
+ ? !(run.staying && ran(branch.then)) && this.holds(branch.if, turn, run)
707
+ : understanding?.branches[`${run.id}/${step.id}/${index}`] === true;
647
708
  const index = (step.branches ?? []).findIndex(hit);
648
709
  if (index < 0) return;
649
710
  const branch = (step.branches ?? [])[index];
@@ -673,10 +734,43 @@ export class Runner<C = unknown, D = unknown> {
673
734
 
674
735
  async advance(turn: Turn<C, D>): Promise<TalkRequest<C, D> | IdleRequest<C, D> | null> {
675
736
  if (turn.ignored) return null;
676
- this.resumeSuspended(turn);
737
+ this.resume(turn);
677
738
  turn.queue = [...turn.session.runs];
678
739
  await this.drain(turn);
679
- return this.speaker(turn);
740
+ // An asker that moved on without a word hands the message to the run it suspended, and that one to the next.
741
+ // Each pass takes a run off the suspended stack, so the stack's size bounds the loop.
742
+ for (let left = turn.session.runs.length; left > 0; left--) {
743
+ if (turn.what.kind !== "message" || turn.silenced !== undefined || turn.talk || turn.spokeBy.size > 0) break;
744
+ const resumed = this.resume(turn);
745
+ if (!resumed) break;
746
+ turn.queue.push(resumed);
747
+ await this.drain(turn);
748
+ }
749
+ const speaker = this.speaker(turn);
750
+ if (speaker && !("idle" in speaker) && this.askFixed(turn, speaker)) {
751
+ turn.talk = undefined;
752
+ return null;
753
+ }
754
+ return speaker;
755
+ }
756
+
757
+ /**
758
+ * Send the step's fixed question when this is its first ask: every field it collects still unknown, none asked yet,
759
+ * and the run not staying (a staying run answers the lead). The question counts as one ask of each field. False when
760
+ * the AI should phrase the ask instead.
761
+ */
762
+ private askFixed(turn: Turn<C, D>, { run, step, pending }: TalkRequest<C, D>): boolean {
763
+ const { question, collect = [] } = step;
764
+ if (question === undefined || run.staying) return false;
765
+ if (pending.length !== collect.length || pending.some((field) => (run.asked[field] ?? 0) > 0)) return false;
766
+ const key = this.stepKey(run, step);
767
+ turn.messages.push({ text: render(question, this.scope(turn, run)), kind: "verbatim", afterMs: turn.afterMs, key, runId: run.id, stepId: step.id });
768
+ turn.afterMs = 0;
769
+ turn.spokeBy.add(run.id);
770
+ turn.spoke = true;
771
+ for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
772
+ this.outcome(turn, run, { kind: "collect", status: "ok", key, code: "asked-fixed", stepId: step.id });
773
+ return true;
680
774
  }
681
775
 
682
776
  /**
@@ -700,12 +794,16 @@ export class Runner<C = unknown, D = unknown> {
700
794
  const instructions = [...(this.options.instructions ?? []), ...own.instructions].filter((ins) => !ins.if || evaluate(ins.if, ctx, conditions));
701
795
  const all = this.options.tools ?? [];
702
796
  const allowed = own.tools;
797
+ // deferTalk leaves a `deferred` outcome as the step's last line; the retry wake re-runs the step without adding one.
798
+ const last = "idle" in talk ? undefined : talk.run.outcomes.at(-1);
799
+ const retry = !("idle" in talk) && last?.status === "deferred" && last.stepId === talk.step.id;
703
800
  return {
704
801
  talk,
705
- input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind },
802
+ input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind, ...(retry ? { retry } : {}) },
706
803
  context: turn.context,
707
804
  data: turn.session.data,
708
805
  history: this.historyOf(turn),
806
+ said: [...turn.messages],
709
807
  now: turn.now,
710
808
  instructions,
711
809
  tools: allowed ? all.filter((tool) => allowed.includes(tool.id)) : all,
@@ -746,6 +844,11 @@ export class Runner<C = unknown, D = unknown> {
746
844
  return;
747
845
  }
748
846
  if (!this.premiseHolds(turn, run, flow)) return;
847
+ // The flow was edited off 'stay' after this run finished its steps: its new `onEnd` decides now.
848
+ if (run.staying && flow.onEnd !== "stay") {
849
+ this.finishFlow(turn, run, flow);
850
+ return;
851
+ }
749
852
  const resuming = run.status === "asking";
750
853
  run.status = "running";
751
854
  if (run.stepId === null) {
@@ -788,6 +891,12 @@ export class Runner<C = unknown, D = unknown> {
788
891
  return ok;
789
892
  }
790
893
 
894
+ /** `silenced: { skip: true }`: the message this step would send is dropped, and the run takes `then` as if it had gone out. */
895
+ private skipSilenced(turn: Turn<C, D>, run: Run, flow: Flow<C, D>, step: Step<C, D>, kind: StepOutcomeKind): void {
896
+ this.outcome(turn, run, { kind, status: "skipped", key: this.stepKey(run, step), code: "silenced", detail: turn.silenced, next: nextLabel(step.then) });
897
+ this.follow(turn, run, flow, step, step.then);
898
+ }
899
+
791
900
  private async execute(turn: Turn<C, D>, run: Run, flow: Flow<C, D>, step: Step<C, D>, resuming: boolean): Promise<void> {
792
901
  const data: Record<string, unknown> = turn.session.data;
793
902
  const key = this.stepKey(run, step);
@@ -797,7 +906,9 @@ export class Runner<C = unknown, D = unknown> {
797
906
  let pending: string[] = [];
798
907
  if (step.collect?.length) {
799
908
  pending = pendingFields(step, data, run.asked);
800
- if (!pending.length) {
909
+ // A `stay` run answers even with nothing left to collect: answering is what it stays for. It only runs on a message
910
+ // (asking runs sit out wakes and events), and on a silenced one the check below keeps it asking.
911
+ if (!pending.length && !run.staying) {
801
912
  this.reportMaxAsks(turn, run, step);
802
913
  this.outcome(turn, run, resuming
803
914
  ? { kind, status: "ok", key, next: nextLabel(step.then) }
@@ -808,6 +919,7 @@ export class Runner<C = unknown, D = unknown> {
808
919
  }
809
920
  if (turn.silenced !== undefined) {
810
921
  if (resuming) run.status = "asking"; // the gate is closed; it speaks when the gate opens
922
+ else if (turn.skipSilenced) this.skipSilenced(turn, run, flow, step, kind);
811
923
  else this.endRun(turn, run, "skipped", "silenced", kind, turn.silenced);
812
924
  return;
813
925
  }
@@ -818,13 +930,16 @@ export class Runner<C = unknown, D = unknown> {
818
930
  }
819
931
  }
820
932
  run.status = "asking";
933
+ // 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.
821
934
  if (!turn.speakDone) turn.talk = { run, flow, step, pending };
935
+ else this.askFixed(turn, { run, flow, step, pending });
822
936
  return;
823
937
  }
824
938
 
825
939
  if (isSay(step)) {
826
940
  if (turn.silenced !== undefined) {
827
- this.endRun(turn, run, "skipped", "silenced", "say", turn.silenced);
941
+ if (turn.skipSilenced) this.skipSilenced(turn, run, flow, step, "say");
942
+ else this.endRun(turn, run, "skipped", "silenced", "say", turn.silenced);
828
943
  return;
829
944
  }
830
945
  if (step.once) {
@@ -870,6 +985,13 @@ export class Runner<C = unknown, D = unknown> {
870
985
  result = { failed: error instanceof Error ? error.message : String(error) };
871
986
  }
872
987
  }
988
+ // The handler already ran, so a defer that cannot be parsed must not throw
989
+ // the turn: every replay would repeat the side effect and fail again.
990
+ if ("defer" in result && !isDuration(String(result.defer))) {
991
+ result = {
992
+ failed: `action "${step.do}" asked to defer by "${String(result.defer)}", which is not a duration. Use a value like "2m" or "1h".`,
993
+ };
994
+ }
873
995
  if ("ok" in result) {
874
996
  if (result.spoke) {
875
997
  turn.spokeBy.add(run.id);
@@ -959,18 +1081,22 @@ export class Runner<C = unknown, D = unknown> {
959
1081
  }
960
1082
  if ("step" in target) {
961
1083
  const data: Record<string, unknown> = turn.session.data;
962
- for (const field of target.clear ?? []) delete data[field];
1084
+ // Forgetting a field means asking for it from scratch: its ask count goes too, so a fixed question goes out again.
1085
+ for (const field of target.clear ?? []) {
1086
+ delete data[field];
1087
+ delete run.asked[field];
1088
+ }
963
1089
  this.jump(turn, run, flow, target.step);
964
1090
  return;
965
1091
  }
966
1092
  const childId = render(target.flow, this.scope(turn, run));
967
1093
  const key = this.stepKey(run, step);
968
1094
  this.endRun(turn, run, "flow");
969
- this.chain(turn, childId, key, target.input ?? run.input, run.hop + 1);
1095
+ this.chain(turn, run, childId, key, target.input ?? run.input, run.hop + 1);
970
1096
  }
971
1097
 
972
- /** Start a child flow that holds the floor and moves in this same phase. */
973
- private chain(turn: Turn<C, D>, flowId: string, key: string, input: unknown, hop: number, keepData = false): void {
1098
+ /** Start a child flow that moves in this same phase. It inherits the floor when its parent held it, or when nobody did. */
1099
+ private chain(turn: Turn<C, D>, parent: Run, flowId: string, key: string, input: unknown, hop: number, keepData = false): void {
974
1100
  const child = this.flows.get(flowId);
975
1101
  if (!child) {
976
1102
  turn.skipped.push({ flowId, anchor: turn.session.id, triggerKey: key, code: "flow-gone", message: OUTCOME_MESSAGES["flow-gone"] });
@@ -978,8 +1104,10 @@ export class Runner<C = unknown, D = unknown> {
978
1104
  }
979
1105
  const run = this.startRun(turn, child, "flow", key, { payload: input, hop, keepData });
980
1106
  if (!run) return;
981
- turn.floorRunId = run.id;
982
- if (turn.ingesting) turn.floorFromIngest = true;
1107
+ if (turn.floorRunId === undefined || turn.floorRunId === parent.id) {
1108
+ turn.floorRunId = run.id;
1109
+ if (turn.ingesting) turn.floorFromIngest = true;
1110
+ }
983
1111
  turn.queue.push(run);
984
1112
  }
985
1113
 
@@ -994,15 +1122,55 @@ export class Runner<C = unknown, D = unknown> {
994
1122
  run.visits[stepId] = (run.visits[stepId] ?? 0) + 1;
995
1123
  run.status = "running";
996
1124
  delete run.waiting;
1125
+ delete run.staying;
1126
+ }
1127
+
1128
+ /** `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. */
1129
+ private stayAt(run: Run, step: Step<C, D>): void {
1130
+ this.enter(run, step.id);
1131
+ run.staying = true;
1132
+ run.status = "asking";
1133
+ }
1134
+
1135
+ /**
1136
+ * 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.
1137
+ * 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
1138
+ * stays on the flow's last talk step. Upgrade: record the talk step on the run when it talks.
1139
+ */
1140
+ private stayStep(run: Run, flow: Flow<C, D>): TalkOf<C, D> | undefined {
1141
+ for (let i = run.outcomes.length - 1; i >= 0; i--) {
1142
+ const { kind, stepId } = run.outcomes[i];
1143
+ const step = kind === "prompt" || kind === "collect" ? this.stepOf(flow, stepId ?? null) : undefined;
1144
+ if (step && isTalk(step)) return step;
1145
+ }
1146
+ return [...flow.steps].reverse().find(isTalk);
997
1147
  }
998
1148
 
999
1149
  private finishFlow(turn: Turn<C, D>, run: Run, flow: Flow<C, D>): void {
1000
1150
  const onEnd = flow.onEnd ?? "end";
1001
1151
  const last = flow.steps[flow.steps.length - 1];
1002
- if (onEnd === "stay" && last) {
1003
- // "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.
1004
- this.enter(run, last.id);
1005
- run.status = "asking";
1152
+ const stay = onEnd === "stay" ? this.stayStep(run, flow) : undefined;
1153
+ if (stay) {
1154
+ // The steps after it ran once, on the way here; staying, it only answers.
1155
+ this.stayAt(run, stay);
1156
+ // One asker at a time. A run the lead's message went to holds the conversation and suspends the other asker;
1157
+ // any other run waits behind that asker and resumes when it is done.
1158
+ const others = turn.session.runs.filter((r) => r !== run && r.status === "asking");
1159
+ if (others.length && !(turn.what.kind === "message" && turn.floorRunId === run.id)) {
1160
+ run.status = "suspended";
1161
+ run.suspendedAt = turn.nowIso;
1162
+ return;
1163
+ }
1164
+ for (const other of others) {
1165
+ other.status = "suspended";
1166
+ other.suspendedAt = turn.nowIso;
1167
+ if (turn.talk?.run === other) turn.talk = undefined;
1168
+ }
1169
+ // A message nothing has answered yet gets its answer now. Reached in Ingest, the run stays asking: Decide judges its
1170
+ // branches as the asker's, and it answers when it moves.
1171
+ if (!turn.ingesting && turn.what.kind === "message" && turn.silenced === undefined && !turn.speakDone && turn.spokeBy.size === 0) {
1172
+ run.status = "running";
1173
+ }
1006
1174
  return;
1007
1175
  }
1008
1176
  if (onEnd === "reset" && last) {
@@ -1010,7 +1178,7 @@ export class Runner<C = unknown, D = unknown> {
1010
1178
  // 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.
1011
1179
  const key = this.stepKey(run, last);
1012
1180
  this.endRun(turn, run, "reset");
1013
- this.chain(turn, flow.id, key, run.input, run.hop + 1, true);
1181
+ this.chain(turn, run, flow.id, key, run.input, run.hop + 1, true);
1014
1182
  return;
1015
1183
  }
1016
1184
  this.endRun(turn, run, "end");
@@ -1033,9 +1201,20 @@ export class Runner<C = unknown, D = unknown> {
1033
1201
  turn.schedule.push({ key: waiting.key, at });
1034
1202
  }
1035
1203
 
1204
+ /** `resumeSuspended`, and on a message the resumed run's `if` branches are judged before it speaks, as the asker's are in Decide. */
1205
+ private resume(turn: Turn<C, D>): Run | undefined {
1206
+ const run = this.resumeSuspended(turn);
1207
+ if (run && turn.what.kind === "message") {
1208
+ const flow = this.flows.get(run.flowId);
1209
+ const step = flow && this.stepOf(flow, run.stepId);
1210
+ if (flow && step && isTalk(step)) this.fireBranch(turn, run, flow, step, null);
1211
+ }
1212
+ return run;
1213
+ }
1214
+
1036
1215
  /** When nobody asks, the most recently suspended run returns to asking. */
1037
- private resumeSuspended(turn: Turn<C, D>): void {
1038
- if (this.asker(turn)) return;
1216
+ private resumeSuspended(turn: Turn<C, D>): Run | undefined {
1217
+ if (this.asker(turn)) return undefined;
1039
1218
  const suspendedAt = (r: Run): string => r.suspendedAt ?? r.startedAt;
1040
1219
  const next = turn.session.runs
1041
1220
  .filter((r) => r.status === "suspended")
@@ -1044,6 +1223,7 @@ export class Runner<C = unknown, D = unknown> {
1044
1223
  next.status = "asking";
1045
1224
  delete next.suspendedAt;
1046
1225
  }
1226
+ return next;
1047
1227
  }
1048
1228
 
1049
1229
  // ── Settle: the one applier after Speak (design §4.7) ─────────────────
@@ -1093,15 +1273,22 @@ export class Runner<C = unknown, D = unknown> {
1093
1273
  Object.assign(turn.session.data, spoken.data);
1094
1274
  this.outcome(turn, run, { kind: talkKind(step), status: "ok", key, llmCalls: spoken.llmCalls, stepId: step.id });
1095
1275
  if (!turn.session.runs.includes(run)) return;
1096
- if (step.collect?.length) {
1097
- const pending = pendingFields(step, turn.session.data, run.asked);
1098
- if (pending.length) {
1099
- for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
1100
- run.status = "asking";
1101
- return;
1276
+ const pending = step.collect?.length ? pendingFields(step, turn.session.data, run.asked) : [];
1277
+ for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
1278
+ if (run.staying) {
1279
+ // 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.
1280
+ const maxAsks = step.maxAsks ?? DEFAULT_MAX_ASKS;
1281
+ for (const field of pending) {
1282
+ if (run.asked[field] === maxAsks) this.outcome(turn, run, { kind: "collect", status: "skipped", key, code: "max-asks", detail: field });
1102
1283
  }
1103
- this.reportMaxAsks(turn, run, step);
1284
+ this.stayAt(run, step);
1285
+ return;
1104
1286
  }
1287
+ if (pending.length) {
1288
+ run.status = "asking";
1289
+ return;
1290
+ }
1291
+ this.reportMaxAsks(turn, run, step);
1105
1292
  run.status = "running";
1106
1293
  this.follow(turn, run, flow, step, step.then);
1107
1294
  if (turn.session.runs.includes(run) && run.status === "running") turn.queue.push(run);
@@ -1139,14 +1326,21 @@ export class Runner<C = unknown, D = unknown> {
1139
1326
  return new Date(reset !== undefined && reset > ladder ? reset : ladder);
1140
1327
  }
1141
1328
 
1142
- /** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
1329
+ /** The assistant spoke this turn: its silence wakes replace the ones its last words set. */
1143
1330
  private armSilence(turn: Turn<C, D>): void {
1331
+ const previous = turn.original?.lastAssistantAt;
1332
+ if (turn.session.lastAssistantAt === previous) return;
1333
+ turn.schedule.push(...this.silenceWakes(turn, previous));
1334
+ }
1335
+
1336
+ /** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
1337
+ private silenceWakes(turn: Turn<C, D>, previous?: string): ScheduleEntry[] {
1144
1338
  const { session } = turn;
1145
1339
  const { lastAssistantAt, lastUserAt } = session;
1146
- if (!lastAssistantAt || lastAssistantAt === turn.original?.lastAssistantAt) return;
1147
- if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt)) return;
1340
+ if (!lastAssistantAt) return [];
1341
+ if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt)) return [];
1148
1342
  const ms = Date.parse(lastAssistantAt);
1149
- const previous = turn.original?.lastAssistantAt;
1343
+ const wakes: ScheduleEntry[] = [];
1150
1344
  for (const flow of this.flows.values()) {
1151
1345
  const trigger = (flow.on ?? []).find((t): t is SilenceTrigger<C, D> => "silence" in t);
1152
1346
  if (!trigger) continue;
@@ -1154,12 +1348,13 @@ export class Runner<C = unknown, D = unknown> {
1154
1348
  if (trigger.if && !this.holds(trigger.if, turn, this.draftRun(turn, flow, "silence", key))) continue;
1155
1349
  if (!this.repeatAllows(turn, flow, trigger, key)) continue;
1156
1350
  const at = this.snap(turn, new Date(ms + parseDuration(trigger.silence)), trigger.businessHours);
1157
- turn.schedule.push({
1351
+ wakes.push({
1158
1352
  key: `silence:${flow.id}:${session.id}:${ms}`,
1159
1353
  at,
1160
1354
  ...(previous ? { replaces: `silence:${flow.id}:${session.id}:${Date.parse(previous)}` } : {}),
1161
1355
  });
1162
1356
  }
1357
+ return wakes;
1163
1358
  }
1164
1359
 
1165
1360
  // ── Return (design §4.8) ──────────────────────────────────────────────