@falai/agent 4.0.0-alpha.8 → 4.0.0

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 (287) hide show
  1. package/README.md +2 -0
  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 +21 -7
  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 +297 -59
  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 +39 -0
  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 +281 -72
  23. package/dist/cjs/core/Runner.js.map +1 -1
  24. package/dist/cjs/core/Speak.d.ts.map +1 -1
  25. package/dist/cjs/core/Speak.js +63 -19
  26. package/dist/cjs/core/Speak.js.map +1 -1
  27. package/dist/cjs/core/Understand.d.ts +6 -3
  28. package/dist/cjs/core/Understand.d.ts.map +1 -1
  29. package/dist/cjs/core/Understand.js +18 -42
  30. package/dist/cjs/core/Understand.js.map +1 -1
  31. package/dist/cjs/core/contracts.d.ts +26 -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 +26 -11
  63. package/dist/cjs/providers/ProviderAdapter.d.ts.map +1 -1
  64. package/dist/cjs/providers/ProviderAdapter.js +13 -13
  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 +14 -3
  69. package/dist/cjs/types/agent.d.ts.map +1 -1
  70. package/dist/cjs/types/ai.d.ts +10 -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 +17 -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 +2 -0
  89. package/dist/cjs/utils/outcomes.d.ts.map +1 -1
  90. package/dist/cjs/utils/outcomes.js +2 -0
  91. package/dist/cjs/utils/outcomes.js.map +1 -1
  92. package/dist/cjs/utils/schema.d.ts +3 -12
  93. package/dist/cjs/utils/schema.d.ts.map +1 -1
  94. package/dist/cjs/utils/schema.js +3 -43
  95. package/dist/cjs/utils/schema.js.map +1 -1
  96. package/dist/cjs/utils/template.d.ts.map +1 -1
  97. package/dist/cjs/utils/template.js +9 -2
  98. package/dist/cjs/utils/template.js.map +1 -1
  99. package/dist/core/Agent.d.ts +8 -1
  100. package/dist/core/Agent.d.ts.map +1 -1
  101. package/dist/core/Agent.js +19 -1
  102. package/dist/core/Agent.js.map +1 -1
  103. package/dist/core/CompactionEngine.d.ts.map +1 -1
  104. package/dist/core/CompactionEngine.js +21 -7
  105. package/dist/core/CompactionEngine.js.map +1 -1
  106. package/dist/core/FlowSpec.d.ts +21 -2
  107. package/dist/core/FlowSpec.d.ts.map +1 -1
  108. package/dist/core/FlowSpec.js +296 -60
  109. package/dist/core/FlowSpec.js.map +1 -1
  110. package/dist/core/Migrate.d.ts.map +1 -1
  111. package/dist/core/Migrate.js +3 -1
  112. package/dist/core/Migrate.js.map +1 -1
  113. package/dist/core/Prompt.d.ts +16 -0
  114. package/dist/core/Prompt.d.ts.map +1 -1
  115. package/dist/core/Prompt.js +37 -0
  116. package/dist/core/Prompt.js.map +1 -1
  117. package/dist/core/Runner.d.ts +37 -4
  118. package/dist/core/Runner.d.ts.map +1 -1
  119. package/dist/core/Runner.js +282 -73
  120. package/dist/core/Runner.js.map +1 -1
  121. package/dist/core/Speak.d.ts.map +1 -1
  122. package/dist/core/Speak.js +64 -20
  123. package/dist/core/Speak.js.map +1 -1
  124. package/dist/core/Understand.d.ts +6 -3
  125. package/dist/core/Understand.d.ts.map +1 -1
  126. package/dist/core/Understand.js +18 -42
  127. package/dist/core/Understand.js.map +1 -1
  128. package/dist/core/contracts.d.ts +26 -6
  129. package/dist/core/contracts.d.ts.map +1 -1
  130. package/dist/index.d.ts +1 -1
  131. package/dist/index.d.ts.map +1 -1
  132. package/dist/persistence/OpenSearchStore.d.ts +2 -1
  133. package/dist/persistence/OpenSearchStore.d.ts.map +1 -1
  134. package/dist/persistence/OpenSearchStore.js +2 -2
  135. package/dist/persistence/OpenSearchStore.js.map +1 -1
  136. package/dist/persistence/RedisStore.d.ts +1 -1
  137. package/dist/persistence/RedisStore.d.ts.map +1 -1
  138. package/dist/providers/AnthropicProvider.d.ts +2 -5
  139. package/dist/providers/AnthropicProvider.d.ts.map +1 -1
  140. package/dist/providers/AnthropicProvider.js +3 -4
  141. package/dist/providers/AnthropicProvider.js.map +1 -1
  142. package/dist/providers/DeepSeekProvider.d.ts.map +1 -1
  143. package/dist/providers/DeepSeekProvider.js +3 -4
  144. package/dist/providers/DeepSeekProvider.js.map +1 -1
  145. package/dist/providers/FallbackAiProvider.js +1 -1
  146. package/dist/providers/FallbackAiProvider.js.map +1 -1
  147. package/dist/providers/GeminiProvider.d.ts +1 -2
  148. package/dist/providers/GeminiProvider.d.ts.map +1 -1
  149. package/dist/providers/GeminiProvider.js +3 -4
  150. package/dist/providers/GeminiProvider.js.map +1 -1
  151. package/dist/providers/GenericOpenAICompatibleProvider.js +4 -4
  152. package/dist/providers/GenericOpenAICompatibleProvider.js.map +1 -1
  153. package/dist/providers/OpenAIProvider.d.ts.map +1 -1
  154. package/dist/providers/OpenAIProvider.js +3 -4
  155. package/dist/providers/OpenAIProvider.js.map +1 -1
  156. package/dist/providers/OpenRouterProvider.d.ts.map +1 -1
  157. package/dist/providers/OpenRouterProvider.js +4 -3
  158. package/dist/providers/OpenRouterProvider.js.map +1 -1
  159. package/dist/providers/ProviderAdapter.d.ts +26 -11
  160. package/dist/providers/ProviderAdapter.d.ts.map +1 -1
  161. package/dist/providers/ProviderAdapter.js +14 -14
  162. package/dist/providers/ProviderAdapter.js.map +1 -1
  163. package/dist/providers/ZaiProvider.js +1 -1
  164. package/dist/providers/ZaiProvider.js.map +1 -1
  165. package/dist/types/agent.d.ts +14 -3
  166. package/dist/types/agent.d.ts.map +1 -1
  167. package/dist/types/ai.d.ts +10 -6
  168. package/dist/types/ai.d.ts.map +1 -1
  169. package/dist/types/compaction.d.ts +1 -0
  170. package/dist/types/compaction.d.ts.map +1 -1
  171. package/dist/types/errors.d.ts +4 -9
  172. package/dist/types/errors.d.ts.map +1 -1
  173. package/dist/types/errors.js +12 -12
  174. package/dist/types/errors.js.map +1 -1
  175. package/dist/types/flow.d.ts +17 -1
  176. package/dist/types/flow.d.ts.map +1 -1
  177. package/dist/types/history.d.ts +0 -7
  178. package/dist/types/history.d.ts.map +1 -1
  179. package/dist/types/index.d.ts +2 -2
  180. package/dist/types/index.d.ts.map +1 -1
  181. package/dist/types/session.d.ts +4 -2
  182. package/dist/types/session.d.ts.map +1 -1
  183. package/dist/utils/clock.js +1 -1
  184. package/dist/utils/clock.js.map +1 -1
  185. package/dist/utils/outcomes.d.ts +2 -0
  186. package/dist/utils/outcomes.d.ts.map +1 -1
  187. package/dist/utils/outcomes.js +2 -0
  188. package/dist/utils/outcomes.js.map +1 -1
  189. package/dist/utils/schema.d.ts +3 -12
  190. package/dist/utils/schema.d.ts.map +1 -1
  191. package/dist/utils/schema.js +3 -42
  192. package/dist/utils/schema.js.map +1 -1
  193. package/dist/utils/template.d.ts.map +1 -1
  194. package/dist/utils/template.js +9 -2
  195. package/dist/utils/template.js.map +1 -1
  196. package/docs/concepts/architecture.md +2 -2
  197. package/docs/concepts/collection.md +40 -5
  198. package/docs/concepts/pipeline.md +10 -7
  199. package/docs/concepts/runs-and-waits.md +2 -2
  200. package/docs/guides/actions-and-events.md +1 -1
  201. package/docs/guides/branching.md +4 -2
  202. package/docs/guides/compaction.md +2 -2
  203. package/docs/guides/error-handling.md +2 -2
  204. package/docs/guides/flow-control.md +4 -2
  205. package/docs/guides/persistence.md +2 -2
  206. package/docs/guides/testing.md +1 -1
  207. package/docs/guides/triggers.md +3 -3
  208. package/docs/migration/v3-to-v4.md +8 -7
  209. package/docs/reference/actions-events-conditions.md +1 -1
  210. package/docs/reference/agent.md +9 -5
  211. package/docs/reference/branches.md +1 -1
  212. package/docs/reference/errors.md +14 -11
  213. package/docs/reference/fields.md +5 -3
  214. package/docs/reference/flow-spec.md +35 -8
  215. package/docs/reference/flow.md +8 -3
  216. package/docs/reference/outcomes.md +4 -2
  217. package/docs/reference/providers.md +5 -3
  218. package/docs/reference/session.md +2 -0
  219. package/docs/reference/step.md +9 -5
  220. package/docs/reference/stores.md +5 -3
  221. package/docs/reference/trigger.md +2 -2
  222. package/docs/rfc/v4-one-flow.md +5 -3
  223. package/docs/start/01-install.md +2 -0
  224. package/docs/start/05-go-to-production.md +18 -1
  225. package/examples/05-branches.ts +1 -1
  226. package/examples/06-triggers-and-waits.ts +4 -3
  227. package/package.json +4 -3
  228. package/src/core/Agent.ts +23 -2
  229. package/src/core/CompactionEngine.ts +25 -9
  230. package/src/core/FlowSpec.ts +311 -72
  231. package/src/core/Migrate.ts +2 -1
  232. package/src/core/Prompt.ts +40 -0
  233. package/src/core/Runner.ts +279 -69
  234. package/src/core/Speak.ts +67 -19
  235. package/src/core/Understand.ts +24 -44
  236. package/src/core/contracts.ts +26 -4
  237. package/src/index.ts +1 -1
  238. package/src/persistence/OpenSearchStore.ts +3 -2
  239. package/src/persistence/RedisStore.ts +1 -1
  240. package/src/providers/AnthropicProvider.ts +4 -9
  241. package/src/providers/DeepSeekProvider.ts +2 -4
  242. package/src/providers/FallbackAiProvider.ts +1 -1
  243. package/src/providers/GeminiProvider.ts +3 -6
  244. package/src/providers/GenericOpenAICompatibleProvider.ts +4 -4
  245. package/src/providers/OpenAIProvider.ts +3 -4
  246. package/src/providers/OpenRouterProvider.ts +4 -2
  247. package/src/providers/ProviderAdapter.ts +35 -33
  248. package/src/providers/ZaiProvider.ts +1 -1
  249. package/src/types/agent.ts +15 -4
  250. package/src/types/ai.ts +10 -6
  251. package/src/types/compaction.ts +1 -0
  252. package/src/types/errors.ts +11 -12
  253. package/src/types/flow.ts +17 -1
  254. package/src/types/history.ts +0 -10
  255. package/src/types/index.ts +1 -1
  256. package/src/types/session.ts +5 -1
  257. package/src/utils/clock.ts +1 -1
  258. package/src/utils/outcomes.ts +2 -0
  259. package/src/utils/schema.ts +3 -48
  260. package/src/utils/template.ts +9 -2
  261. package/dist/cjs/providers/index.d.ts +0 -26
  262. package/dist/cjs/providers/index.d.ts.map +0 -1
  263. package/dist/cjs/providers/index.js +0 -30
  264. package/dist/cjs/providers/index.js.map +0 -1
  265. package/dist/cjs/utils/clone.d.ts +0 -8
  266. package/dist/cjs/utils/clone.d.ts.map +0 -1
  267. package/dist/cjs/utils/clone.js +0 -32
  268. package/dist/cjs/utils/clone.js.map +0 -1
  269. package/dist/cjs/utils/index.d.ts +0 -9
  270. package/dist/cjs/utils/index.d.ts.map +0 -1
  271. package/dist/cjs/utils/index.js +0 -29
  272. package/dist/cjs/utils/index.js.map +0 -1
  273. package/dist/providers/index.d.ts +0 -26
  274. package/dist/providers/index.d.ts.map +0 -1
  275. package/dist/providers/index.js +0 -17
  276. package/dist/providers/index.js.map +0 -1
  277. package/dist/utils/clone.d.ts +0 -8
  278. package/dist/utils/clone.d.ts.map +0 -1
  279. package/dist/utils/clone.js +0 -29
  280. package/dist/utils/clone.js.map +0 -1
  281. package/dist/utils/index.d.ts +0 -9
  282. package/dist/utils/index.d.ts.map +0 -1
  283. package/dist/utils/index.js +0 -9
  284. package/dist/utils/index.js.map +0 -1
  285. package/src/providers/index.ts +0 -38
  286. package/src/utils/clone.ts +0 -34
  287. 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"];
@@ -86,6 +87,8 @@ export interface Turn<C = unknown, D = unknown> {
86
87
  /** A short `wait` waiting to ride on the next message. */
87
88
  afterMs: number;
88
89
  talk?: TalkRequest<C, D>;
90
+ /** Understand's judgment that the lead's message asks something; absent when nothing judged it. */
91
+ asks?: boolean;
89
92
  /** Speak already happened (or was skipped): a talk step reached now waits for the next message. */
90
93
  speakDone: boolean;
91
94
  /** Runs still to move this phase; `follow` appends children. */
@@ -137,6 +140,13 @@ function talkKind(step: { collect?: readonly string[] }): StepOutcomeKind {
137
140
  return step.collect?.length ? "collect" : "prompt";
138
141
  }
139
142
 
143
+ /** Everything a flow collects: its own `collect`, then each talk step's, once each. */
144
+ function flowFields<C, D>(flow: Flow<C, D>): string[] {
145
+ const fields = new Set<string>(flow.collect);
146
+ for (const step of flow.steps) if (isTalk(step)) for (const field of step.collect ?? []) fields.add(field);
147
+ return [...fields];
148
+ }
149
+
140
150
  function kindOf<C, D>(step: Step<C, D> | undefined): StepOutcomeKind {
141
151
  if (!step) return "if";
142
152
  if (isTalk(step)) return talkKind(step);
@@ -171,16 +181,31 @@ export class Runner<C = unknown, D = unknown> {
171
181
  // ── Load + Ingest ─────────────────────────────────────────────────────
172
182
 
173
183
  begin(input: TurnInput<C, D>): Turn<C, D> {
174
- const now = (this.options.clock ?? (() => new Date()))();
184
+ const now = this.now();
185
+ const turn = this.open(input, describe(input, now.toISOString()), now);
186
+ if (turn.what.kind === "wake" && !input.session) {
187
+ this.ignore(turn, "no-session", turn.what.key);
188
+ return turn;
189
+ }
190
+ turn.ingesting = true;
191
+ this.ingest(turn);
192
+ turn.ingesting = false;
193
+ return turn;
194
+ }
195
+
196
+ private now(): Date {
197
+ return (this.options.clock ?? (() => new Date()))();
198
+ }
199
+
200
+ private open(input: TurnBase<C, D>, what: What, now: Date): Turn<C, D> {
175
201
  const nowIso = now.toISOString();
176
- const what = describe(input, nowIso);
177
202
  const silenced = typeof input.silenced === "string" ? input.silenced : input.silenced?.reason;
178
- const turn: Turn<C, D> = {
203
+ return {
179
204
  input,
180
205
  what,
181
206
  kind: what.kind,
182
207
  triggerKey: what.kind === "message" ? (what.id ?? what.at) : what.key,
183
- session: input.session ? cloneDeep(input.session) : freshSession<D>(input.sessionId),
208
+ session: input.session ? structuredClone(input.session) : freshSession<D>(input.sessionId),
184
209
  original: input.session,
185
210
  now,
186
211
  nowIso,
@@ -188,6 +213,7 @@ export class Runner<C = unknown, D = unknown> {
188
213
  context: input.context as C,
189
214
  silenced,
190
215
  understandAllowed: typeof input.silenced === "object" && input.silenced.understand === true,
216
+ skipSilenced: typeof input.silenced === "object" && input.silenced.skip === true,
191
217
  ignored: false,
192
218
  messages: [],
193
219
  schedule: [],
@@ -204,14 +230,17 @@ export class Runner<C = unknown, D = unknown> {
204
230
  speakDone: false,
205
231
  queue: [],
206
232
  };
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;
233
+ }
234
+
235
+ /** Every wake the session waits on: each parked run's own, then each silence flow's since the assistant last spoke. */
236
+ pendingWakes(input: PendingWakesInput<C, D>): ScheduleEntry[] {
237
+ // No input arrives, so nothing reads `what`: the inert wake shape only fills the field.
238
+ const turn = this.open({ ...input, sessionId: input.session.id }, { kind: "wake", key: "" }, this.now());
239
+ // `park` always sets both; the type leaves them optional, so a hand-built blob without them has no wake to give.
240
+ const parked = turn.session.runs.flatMap(({ status, waiting }) =>
241
+ status === "waiting" && waiting?.key && waiting.until ? [{ key: waiting.key, at: new Date(waiting.until) }] : [],
242
+ );
243
+ return [...parked, ...this.silenceWakes(turn)];
215
244
  }
216
245
 
217
246
  private ingest(turn: Turn<C, D>): void {
@@ -285,6 +314,8 @@ export class Runner<C = unknown, D = unknown> {
285
314
  const branch = step.branches?.find((b) => "if" in b && this.holds(b.if, turn, run));
286
315
  const target = branch ? branch.then : step.else;
287
316
  this.outcome(turn, run, { kind: "wait", status: "ok", code: "replied", next: nextLabel(target) });
317
+ // The reply is this run's while it moves, so a stay it reaches answers it.
318
+ if (!turn.floorFromIngest) turn.floorRunId = run.id;
288
319
  this.follow(turn, run, flow, step, target);
289
320
  this.takeFloor(turn, run);
290
321
  }
@@ -323,8 +354,10 @@ export class Runner<C = unknown, D = unknown> {
323
354
  /** `silence:<flowId>:<sessionId>:<lastAssistantAtMs>`, honoured only while the blob still shows that silence. */
324
355
  private silenceWake(turn: Turn<C, D>, key: string): void {
325
356
  const rest = key.slice("silence:".length);
326
- const flowId = rest.slice(0, rest.indexOf(":"));
327
357
  const ms = Number(rest.slice(rest.lastIndexOf(":") + 1));
358
+ // A flow id may hold a ":" itself, so cut at the session id rather than at the first ":".
359
+ const cut = rest.lastIndexOf(`:${turn.session.id}:`);
360
+ const flowId = rest.slice(0, cut === -1 ? rest.indexOf(":") : cut);
328
361
  const { lastAssistantAt } = turn.session;
329
362
  const flow = this.flows.get(flowId);
330
363
  if (!flow) {
@@ -386,11 +419,8 @@ export class Runner<C = unknown, D = unknown> {
386
419
  return null;
387
420
  };
388
421
  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
- }
422
+ const blocked = this.repeatBlocks(turn, dedupeKey, repeat);
423
+ if (blocked) return skip(blocked);
394
424
  if (hop >= MAX_HOP) return skip("hop-limit");
395
425
  const live = session.runs.find((r) => r.flowId === flow.id && r.anchor === anchor);
396
426
  if (live) {
@@ -407,9 +437,11 @@ export class Runner<C = unknown, D = unknown> {
407
437
  }
408
438
  session.runs.push(run);
409
439
  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);
440
+ const eventTrigger = opts.trigger && "event" in opts.trigger ? opts.trigger : undefined;
441
+ const after = eventTrigger?.after;
442
+ const at = this.snap(turn, new Date(turn.now.getTime() + (after ? parseDuration(after) : 0)), eventTrigger?.businessHours);
443
+ // With no `after`, only a closed hour parks the start: inside working hours the snap returns now.
444
+ if (after || at.getTime() > turn.now.getTime()) {
413
445
  this.park(turn, run, { kind: "timer", key: `${run.id}:start:${at.getTime()}` }, at);
414
446
  this.outcome(turn, run, { kind: "wait", status: "waiting", code: "awaiting-trigger", until: at.toISOString() });
415
447
  }
@@ -433,10 +465,15 @@ export class Runner<C = unknown, D = unknown> {
433
465
 
434
466
  private repeatAllows(turn: Turn<C, D>, flow: Flow<C, D>, trigger: Trigger<C, D>, key: string): boolean {
435
467
  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);
468
+ return !this.repeatBlocks(turn, `${flow.id}:${this.anchorOf(turn, flow)}:${repeat === "always" ? key : ""}`, repeat);
469
+ }
470
+
471
+ /** Why the claim already on `dedupeKey` stops another start, or nothing when there is none or its cooldown has run out. */
472
+ private repeatBlocks(turn: Turn<C, D>, dedupeKey: string, repeat: Repeat): "already-claimed" | "cooldown" | undefined {
473
+ const heldAt = this.claimAt(turn, dedupeKey);
474
+ if (heldAt === undefined) return undefined;
475
+ if (typeof repeat === "string") return "already-claimed";
476
+ return turn.now.getTime() - Date.parse(heldAt) < parseDuration(repeat.cooldown) ? "cooldown" : undefined;
440
477
  }
441
478
 
442
479
  /** A run as it would be if started now, for trigger-level predicates. */
@@ -521,17 +558,27 @@ export class Runner<C = unknown, D = unknown> {
521
558
  }
522
559
  const fields: UnderstandRequest<C, D>["fields"] = {};
523
560
  const data: Record<string, unknown> = turn.session.data;
561
+ const harvestable = (field: string): boolean => {
562
+ const def = this.options.fields[field];
563
+ return def !== undefined && !isKnown(data[field]) && extractMode(def) === "anywhere";
564
+ };
524
565
  for (const flow of [...(floorFlow ? [floorFlow] : []), ...eligible]) {
525
- for (const step of flow.steps) {
526
- if (!isTalk(step)) continue;
527
- for (const field of step.collect ?? []) {
528
- const def = this.options.fields[field];
529
- if (def && !isKnown(data[field]) && extractMode(def) === "anywhere") fields[field] = def;
530
- }
531
- }
566
+ for (const field of flowFields(flow).filter(harvestable)) fields[field] = this.options.fields[field];
532
567
  }
533
- if (!messageFlows.length && !mentionFlows.length && !branches.length && !Object.keys(fields).length) return null;
568
+ const judging = messageFlows.length > 0 || mentionFlows.length > 0 || branches.length > 0 || Object.keys(fields).length > 0;
569
+ // With nobody on the floor the catch-all may take this message, and an opening message often says the most. The
570
+ // 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.
571
+ // 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.
572
+ const fallback = floorRun ? undefined : this.messageFlows(turn, (list) => list.length === 0)[0];
573
+ if (fallback) {
574
+ const first = fallback.steps[0];
575
+ const free = new Set<string>(first && isTalk(first) && first.question === undefined ? first.collect : []);
576
+ const own = flowFields(fallback).filter(harvestable);
577
+ if (judging || own.some((field) => !free.has(field))) for (const field of own) fields[field] = this.options.fields[field];
578
+ }
579
+ if (!judging && !Object.keys(fields).length) return null;
534
580
  return {
581
+ sessionId: turn.session.id,
535
582
  text: turn.what.text,
536
583
  history: this.historyOf(turn),
537
584
  context: turn.context,
@@ -597,6 +644,7 @@ export class Runner<C = unknown, D = unknown> {
597
644
  if (turn.ignored || turn.what.kind !== "message") return;
598
645
  turn.llmCalls += understanding?.llmCalls ?? 0;
599
646
  turn.usage = addUsage(turn.usage, understanding?.usage);
647
+ if (understanding?.asks !== undefined) turn.asks = understanding.asks;
600
648
  const key = turn.triggerKey;
601
649
  const asker = this.asker(turn);
602
650
 
@@ -639,6 +687,9 @@ export class Runner<C = unknown, D = unknown> {
639
687
  }
640
688
  }
641
689
 
690
+ // Unless routing or Ingest moved it, the message is the asker's: written down, so a chain beside it cannot take it.
691
+ if (asker && !route && !turn.floorFromIngest) turn.floorRunId = asker.id;
692
+
642
693
  for (const [field, raw] of Object.entries(understanding?.fields ?? {})) this.writeField(turn, field, raw);
643
694
 
644
695
  if (asker && turn.session.runs.includes(asker) && asker.status === "asking") {
@@ -649,8 +700,15 @@ export class Runner<C = unknown, D = unknown> {
649
700
  }
650
701
 
651
702
  private fireBranch(turn: Turn<C, D>, run: Run, flow: Flow<C, D>, step: TalkOf<C, D>, understanding: Understanding | null): void {
703
+ // Staying, a run does not take an `if` branch back onto a path it has already run: the fact it tests is still true,
704
+ // so it would re-run that path on every message. A `when` branch is judged on each new message and always counts.
705
+ // 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`.
706
+ const ran = (then: Next<D>): boolean =>
707
+ typeof then === "string" ? then === "end" || (run.visits[then] ?? 0) > 0 : "step" in then && (run.visits[then.step] ?? 0) > 0;
652
708
  const hit = (branch: Branch<C, D>, index: number): boolean =>
653
- "if" in branch ? this.holds(branch.if, turn, run) : understanding?.branches[`${run.id}/${step.id}/${index}`] === true;
709
+ "if" in branch
710
+ ? !(run.staying && ran(branch.then)) && this.holds(branch.if, turn, run)
711
+ : understanding?.branches[`${run.id}/${step.id}/${index}`] === true;
654
712
  const index = (step.branches ?? []).findIndex(hit);
655
713
  if (index < 0) return;
656
714
  const branch = (step.branches ?? [])[index];
@@ -680,10 +738,61 @@ export class Runner<C = unknown, D = unknown> {
680
738
 
681
739
  async advance(turn: Turn<C, D>): Promise<TalkRequest<C, D> | IdleRequest<C, D> | null> {
682
740
  if (turn.ignored) return null;
683
- this.resumeSuspended(turn);
741
+ this.resume(turn);
684
742
  turn.queue = [...turn.session.runs];
685
743
  await this.drain(turn);
686
- return this.speaker(turn);
744
+ // An asker that moved on without a word hands the message to the run it suspended, and that one to the next.
745
+ // Each pass takes a run off the suspended stack, so the stack's size bounds the loop.
746
+ for (let left = turn.session.runs.length; left > 0; left--) {
747
+ if (turn.what.kind !== "message" || turn.silenced !== undefined || turn.talk || turn.spokeBy.size > 0) break;
748
+ const resumed = this.resume(turn);
749
+ if (!resumed) break;
750
+ turn.queue.push(resumed);
751
+ await this.drain(turn);
752
+ }
753
+ const speaker = this.speaker(turn);
754
+ if (!speaker || "idle" in speaker || !this.fixedDue(speaker)) return speaker;
755
+ // A question the lead asked gets its answer: the step speaks once, answering it and asking the fixed question.
756
+ // Appending the text word for word after an answer could repeat or contradict it. Unjudged counts as asked,
757
+ // and so does the retry of a failed answer: a question sent over the lead's own drops it.
758
+ const answerFirst = turn.what.kind === "message" ? turn.asks !== false : this.retrying(speaker);
759
+ if (answerFirst && speaker.step.question !== undefined) {
760
+ speaker.fixedQuestion = render(speaker.step.question, this.scope(turn, speaker.run));
761
+ return speaker;
762
+ }
763
+ this.askFixed(turn, speaker);
764
+ turn.talk = undefined;
765
+ return null;
766
+ }
767
+
768
+ /** The step failed at the provider and runs again on its retry wake: deferTalk left `deferred` as its last line. */
769
+ private retrying({ run, step }: TalkRequest<C, D>): boolean {
770
+ const last = run.outcomes.at(-1);
771
+ return last?.status === "deferred" && last.stepId === step.id;
772
+ }
773
+
774
+ /**
775
+ * The step's fixed question is due: this is its first ask, with every field it collects still unknown and none asked
776
+ * yet, and the run is not staying (a staying run answers the lead). Otherwise the AI phrases the ask.
777
+ */
778
+ private fixedDue({ run, step, pending }: TalkRequest<C, D>): boolean {
779
+ const { question, collect = [] } = step;
780
+ if (question === undefined || run.staying) return false;
781
+ return pending.length === collect.length && pending.every((field) => (run.asked[field] ?? 0) === 0);
782
+ }
783
+
784
+ /** Send the step's fixed question when it is due. It counts as one ask of each field. */
785
+ private askFixed(turn: Turn<C, D>, talk: TalkRequest<C, D>): boolean {
786
+ const { run, step, pending } = talk;
787
+ if (!this.fixedDue(talk) || step.question === undefined) return false;
788
+ const key = this.stepKey(run, step);
789
+ turn.messages.push({ text: render(step.question, this.scope(turn, run)), kind: "verbatim", afterMs: turn.afterMs, key, runId: run.id, stepId: step.id });
790
+ turn.afterMs = 0;
791
+ turn.spokeBy.add(run.id);
792
+ turn.spoke = true;
793
+ for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
794
+ this.outcome(turn, run, { kind: "collect", status: "ok", key, code: "asked-fixed", stepId: step.id });
795
+ return true;
687
796
  }
688
797
 
689
798
  /**
@@ -707,12 +816,15 @@ export class Runner<C = unknown, D = unknown> {
707
816
  const instructions = [...(this.options.instructions ?? []), ...own.instructions].filter((ins) => !ins.if || evaluate(ins.if, ctx, conditions));
708
817
  const all = this.options.tools ?? [];
709
818
  const allowed = own.tools;
819
+ const retry = !("idle" in talk) && this.retrying(talk);
710
820
  return {
821
+ sessionId: turn.session.id,
711
822
  talk,
712
- input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind },
823
+ input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind, ...(retry ? { retry } : {}) },
713
824
  context: turn.context,
714
825
  data: turn.session.data,
715
826
  history: this.historyOf(turn),
827
+ said: [...turn.messages],
716
828
  now: turn.now,
717
829
  instructions,
718
830
  tools: allowed ? all.filter((tool) => allowed.includes(tool.id)) : all,
@@ -753,6 +865,11 @@ export class Runner<C = unknown, D = unknown> {
753
865
  return;
754
866
  }
755
867
  if (!this.premiseHolds(turn, run, flow)) return;
868
+ // The flow was edited off 'stay' after this run finished its steps: its new `onEnd` decides now.
869
+ if (run.staying && flow.onEnd !== "stay") {
870
+ this.finishFlow(turn, run, flow);
871
+ return;
872
+ }
756
873
  const resuming = run.status === "asking";
757
874
  run.status = "running";
758
875
  if (run.stepId === null) {
@@ -795,6 +912,12 @@ export class Runner<C = unknown, D = unknown> {
795
912
  return ok;
796
913
  }
797
914
 
915
+ /** `silenced: { skip: true }`: the message this step would send is dropped, and the run takes `then` as if it had gone out. */
916
+ private skipSilenced(turn: Turn<C, D>, run: Run, flow: Flow<C, D>, step: Step<C, D>, kind: StepOutcomeKind): void {
917
+ this.outcome(turn, run, { kind, status: "skipped", key: this.stepKey(run, step), code: "silenced", detail: turn.silenced, next: nextLabel(step.then) });
918
+ this.follow(turn, run, flow, step, step.then);
919
+ }
920
+
798
921
  private async execute(turn: Turn<C, D>, run: Run, flow: Flow<C, D>, step: Step<C, D>, resuming: boolean): Promise<void> {
799
922
  const data: Record<string, unknown> = turn.session.data;
800
923
  const key = this.stepKey(run, step);
@@ -804,7 +927,9 @@ export class Runner<C = unknown, D = unknown> {
804
927
  let pending: string[] = [];
805
928
  if (step.collect?.length) {
806
929
  pending = pendingFields(step, data, run.asked);
807
- if (!pending.length) {
930
+ // A `stay` run answers even with nothing left to collect: answering is what it stays for. It only runs on a message
931
+ // (asking runs sit out wakes and events), and on a silenced one the check below keeps it asking.
932
+ if (!pending.length && !run.staying) {
808
933
  this.reportMaxAsks(turn, run, step);
809
934
  this.outcome(turn, run, resuming
810
935
  ? { kind, status: "ok", key, next: nextLabel(step.then) }
@@ -815,6 +940,7 @@ export class Runner<C = unknown, D = unknown> {
815
940
  }
816
941
  if (turn.silenced !== undefined) {
817
942
  if (resuming) run.status = "asking"; // the gate is closed; it speaks when the gate opens
943
+ else if (turn.skipSilenced) this.skipSilenced(turn, run, flow, step, kind);
818
944
  else this.endRun(turn, run, "skipped", "silenced", kind, turn.silenced);
819
945
  return;
820
946
  }
@@ -825,13 +951,16 @@ export class Runner<C = unknown, D = unknown> {
825
951
  }
826
952
  }
827
953
  run.status = "asking";
954
+ // 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.
828
955
  if (!turn.speakDone) turn.talk = { run, flow, step, pending };
956
+ else this.askFixed(turn, { run, flow, step, pending });
829
957
  return;
830
958
  }
831
959
 
832
960
  if (isSay(step)) {
833
961
  if (turn.silenced !== undefined) {
834
- this.endRun(turn, run, "skipped", "silenced", "say", turn.silenced);
962
+ if (turn.skipSilenced) this.skipSilenced(turn, run, flow, step, "say");
963
+ else this.endRun(turn, run, "skipped", "silenced", "say", turn.silenced);
835
964
  return;
836
965
  }
837
966
  if (step.once) {
@@ -877,6 +1006,13 @@ export class Runner<C = unknown, D = unknown> {
877
1006
  result = { failed: error instanceof Error ? error.message : String(error) };
878
1007
  }
879
1008
  }
1009
+ // The handler already ran, so a defer that cannot be parsed must not throw
1010
+ // the turn: every replay would repeat the side effect and fail again.
1011
+ if ("defer" in result && !isDuration(String(result.defer))) {
1012
+ result = {
1013
+ failed: `action "${step.do}" asked to defer by "${String(result.defer)}", which is not a duration. Use a value like "2m" or "1h".`,
1014
+ };
1015
+ }
880
1016
  if ("ok" in result) {
881
1017
  if (result.spoke) {
882
1018
  turn.spokeBy.add(run.id);
@@ -966,18 +1102,22 @@ export class Runner<C = unknown, D = unknown> {
966
1102
  }
967
1103
  if ("step" in target) {
968
1104
  const data: Record<string, unknown> = turn.session.data;
969
- for (const field of target.clear ?? []) delete data[field];
1105
+ // Forgetting a field means asking for it from scratch: its ask count goes too, so a fixed question goes out again.
1106
+ for (const field of target.clear ?? []) {
1107
+ delete data[field];
1108
+ delete run.asked[field];
1109
+ }
970
1110
  this.jump(turn, run, flow, target.step);
971
1111
  return;
972
1112
  }
973
1113
  const childId = render(target.flow, this.scope(turn, run));
974
1114
  const key = this.stepKey(run, step);
975
1115
  this.endRun(turn, run, "flow");
976
- this.chain(turn, childId, key, target.input ?? run.input, run.hop + 1);
1116
+ this.chain(turn, run, childId, key, target.input ?? run.input, run.hop + 1);
977
1117
  }
978
1118
 
979
- /** Start a child flow that holds the floor and moves in this same phase. */
980
- private chain(turn: Turn<C, D>, flowId: string, key: string, input: unknown, hop: number, keepData = false): void {
1119
+ /** Start a child flow that moves in this same phase. It inherits the floor when its parent held it, or when nobody did. */
1120
+ private chain(turn: Turn<C, D>, parent: Run, flowId: string, key: string, input: unknown, hop: number, keepData = false): void {
981
1121
  const child = this.flows.get(flowId);
982
1122
  if (!child) {
983
1123
  turn.skipped.push({ flowId, anchor: turn.session.id, triggerKey: key, code: "flow-gone", message: OUTCOME_MESSAGES["flow-gone"] });
@@ -985,8 +1125,10 @@ export class Runner<C = unknown, D = unknown> {
985
1125
  }
986
1126
  const run = this.startRun(turn, child, "flow", key, { payload: input, hop, keepData });
987
1127
  if (!run) return;
988
- turn.floorRunId = run.id;
989
- if (turn.ingesting) turn.floorFromIngest = true;
1128
+ if (turn.floorRunId === undefined || turn.floorRunId === parent.id) {
1129
+ turn.floorRunId = run.id;
1130
+ if (turn.ingesting) turn.floorFromIngest = true;
1131
+ }
990
1132
  turn.queue.push(run);
991
1133
  }
992
1134
 
@@ -1001,15 +1143,55 @@ export class Runner<C = unknown, D = unknown> {
1001
1143
  run.visits[stepId] = (run.visits[stepId] ?? 0) + 1;
1002
1144
  run.status = "running";
1003
1145
  delete run.waiting;
1146
+ delete run.staying;
1147
+ }
1148
+
1149
+ /** `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. */
1150
+ private stayAt(run: Run, step: Step<C, D>): void {
1151
+ this.enter(run, step.id);
1152
+ run.staying = true;
1153
+ run.status = "asking";
1154
+ }
1155
+
1156
+ /**
1157
+ * 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.
1158
+ * 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
1159
+ * stays on the flow's last talk step. Upgrade: record the talk step on the run when it talks.
1160
+ */
1161
+ private stayStep(run: Run, flow: Flow<C, D>): TalkOf<C, D> | undefined {
1162
+ for (let i = run.outcomes.length - 1; i >= 0; i--) {
1163
+ const { kind, stepId } = run.outcomes[i];
1164
+ const step = kind === "prompt" || kind === "collect" ? this.stepOf(flow, stepId ?? null) : undefined;
1165
+ if (step && isTalk(step)) return step;
1166
+ }
1167
+ return [...flow.steps].reverse().find(isTalk);
1004
1168
  }
1005
1169
 
1006
1170
  private finishFlow(turn: Turn<C, D>, run: Run, flow: Flow<C, D>): void {
1007
1171
  const onEnd = flow.onEnd ?? "end";
1008
1172
  const last = flow.steps[flow.steps.length - 1];
1009
- if (onEnd === "stay" && last) {
1010
- // "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.
1011
- this.enter(run, last.id);
1012
- run.status = "asking";
1173
+ const stay = onEnd === "stay" ? this.stayStep(run, flow) : undefined;
1174
+ if (stay) {
1175
+ // The steps after it ran once, on the way here; staying, it only answers.
1176
+ this.stayAt(run, stay);
1177
+ // One asker at a time. A run the lead's message went to holds the conversation and suspends the other asker;
1178
+ // any other run waits behind that asker and resumes when it is done.
1179
+ const others = turn.session.runs.filter((r) => r !== run && r.status === "asking");
1180
+ if (others.length && !(turn.what.kind === "message" && turn.floorRunId === run.id)) {
1181
+ run.status = "suspended";
1182
+ run.suspendedAt = turn.nowIso;
1183
+ return;
1184
+ }
1185
+ for (const other of others) {
1186
+ other.status = "suspended";
1187
+ other.suspendedAt = turn.nowIso;
1188
+ if (turn.talk?.run === other) turn.talk = undefined;
1189
+ }
1190
+ // A message nothing has answered yet gets its answer now. Reached in Ingest, the run stays asking: Decide judges its
1191
+ // branches as the asker's, and it answers when it moves.
1192
+ if (!turn.ingesting && turn.what.kind === "message" && turn.silenced === undefined && !turn.speakDone && turn.spokeBy.size === 0) {
1193
+ run.status = "running";
1194
+ }
1013
1195
  return;
1014
1196
  }
1015
1197
  if (onEnd === "reset" && last) {
@@ -1017,7 +1199,7 @@ export class Runner<C = unknown, D = unknown> {
1017
1199
  // 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.
1018
1200
  const key = this.stepKey(run, last);
1019
1201
  this.endRun(turn, run, "reset");
1020
- this.chain(turn, flow.id, key, run.input, run.hop + 1, true);
1202
+ this.chain(turn, run, flow.id, key, run.input, run.hop + 1, true);
1021
1203
  return;
1022
1204
  }
1023
1205
  this.endRun(turn, run, "end");
@@ -1040,9 +1222,20 @@ export class Runner<C = unknown, D = unknown> {
1040
1222
  turn.schedule.push({ key: waiting.key, at });
1041
1223
  }
1042
1224
 
1225
+ /** `resumeSuspended`, and on a message the resumed run's `if` branches are judged before it speaks, as the asker's are in Decide. */
1226
+ private resume(turn: Turn<C, D>): Run | undefined {
1227
+ const run = this.resumeSuspended(turn);
1228
+ if (run && turn.what.kind === "message") {
1229
+ const flow = this.flows.get(run.flowId);
1230
+ const step = flow && this.stepOf(flow, run.stepId);
1231
+ if (flow && step && isTalk(step)) this.fireBranch(turn, run, flow, step, null);
1232
+ }
1233
+ return run;
1234
+ }
1235
+
1043
1236
  /** When nobody asks, the most recently suspended run returns to asking. */
1044
- private resumeSuspended(turn: Turn<C, D>): void {
1045
- if (this.asker(turn)) return;
1237
+ private resumeSuspended(turn: Turn<C, D>): Run | undefined {
1238
+ if (this.asker(turn)) return undefined;
1046
1239
  const suspendedAt = (r: Run): string => r.suspendedAt ?? r.startedAt;
1047
1240
  const next = turn.session.runs
1048
1241
  .filter((r) => r.status === "suspended")
@@ -1051,6 +1244,7 @@ export class Runner<C = unknown, D = unknown> {
1051
1244
  next.status = "asking";
1052
1245
  delete next.suspendedAt;
1053
1246
  }
1247
+ return next;
1054
1248
  }
1055
1249
 
1056
1250
  // ── Settle: the one applier after Speak (design §4.7) ─────────────────
@@ -1098,17 +1292,25 @@ export class Runner<C = unknown, D = unknown> {
1098
1292
  }
1099
1293
  for (const [field, raw] of Object.entries(spoken.fields)) this.writeField(turn, field, raw);
1100
1294
  Object.assign(turn.session.data, spoken.data);
1101
- this.outcome(turn, run, { kind: talkKind(step), status: "ok", key, llmCalls: spoken.llmCalls, stepId: step.id });
1295
+ const fixed = talk.fixedQuestion !== undefined ? { code: "fixed-in-reply" as const } : {};
1296
+ this.outcome(turn, run, { kind: talkKind(step), status: "ok", key, llmCalls: spoken.llmCalls, stepId: step.id, ...fixed });
1102
1297
  if (!turn.session.runs.includes(run)) return;
1103
- if (step.collect?.length) {
1104
- const pending = pendingFields(step, turn.session.data, run.asked);
1105
- if (pending.length) {
1106
- for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
1107
- run.status = "asking";
1108
- return;
1298
+ const pending = step.collect?.length ? pendingFields(step, turn.session.data, run.asked) : [];
1299
+ for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
1300
+ if (run.staying) {
1301
+ // 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.
1302
+ const maxAsks = step.maxAsks ?? DEFAULT_MAX_ASKS;
1303
+ for (const field of pending) {
1304
+ if (run.asked[field] === maxAsks) this.outcome(turn, run, { kind: "collect", status: "skipped", key, code: "max-asks", detail: field });
1109
1305
  }
1110
- this.reportMaxAsks(turn, run, step);
1306
+ this.stayAt(run, step);
1307
+ return;
1111
1308
  }
1309
+ if (pending.length) {
1310
+ run.status = "asking";
1311
+ return;
1312
+ }
1313
+ this.reportMaxAsks(turn, run, step);
1112
1314
  run.status = "running";
1113
1315
  this.follow(turn, run, flow, step, step.then);
1114
1316
  if (turn.session.runs.includes(run) && run.status === "running") turn.queue.push(run);
@@ -1146,14 +1348,21 @@ export class Runner<C = unknown, D = unknown> {
1146
1348
  return new Date(reset !== undefined && reset > ladder ? reset : ladder);
1147
1349
  }
1148
1350
 
1149
- /** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
1351
+ /** The assistant spoke this turn: its silence wakes replace the ones its last words set. */
1150
1352
  private armSilence(turn: Turn<C, D>): void {
1353
+ const previous = turn.original?.lastAssistantAt;
1354
+ if (turn.session.lastAssistantAt === previous) return;
1355
+ turn.schedule.push(...this.silenceWakes(turn, previous));
1356
+ }
1357
+
1358
+ /** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
1359
+ private silenceWakes(turn: Turn<C, D>, previous?: string): ScheduleEntry[] {
1151
1360
  const { session } = turn;
1152
1361
  const { lastAssistantAt, lastUserAt } = session;
1153
- if (!lastAssistantAt || lastAssistantAt === turn.original?.lastAssistantAt) return;
1154
- if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt)) return;
1362
+ if (!lastAssistantAt) return [];
1363
+ if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt)) return [];
1155
1364
  const ms = Date.parse(lastAssistantAt);
1156
- const previous = turn.original?.lastAssistantAt;
1365
+ const wakes: ScheduleEntry[] = [];
1157
1366
  for (const flow of this.flows.values()) {
1158
1367
  const trigger = (flow.on ?? []).find((t): t is SilenceTrigger<C, D> => "silence" in t);
1159
1368
  if (!trigger) continue;
@@ -1161,12 +1370,13 @@ export class Runner<C = unknown, D = unknown> {
1161
1370
  if (trigger.if && !this.holds(trigger.if, turn, this.draftRun(turn, flow, "silence", key))) continue;
1162
1371
  if (!this.repeatAllows(turn, flow, trigger, key)) continue;
1163
1372
  const at = this.snap(turn, new Date(ms + parseDuration(trigger.silence)), trigger.businessHours);
1164
- turn.schedule.push({
1373
+ wakes.push({
1165
1374
  key: `silence:${flow.id}:${session.id}:${ms}`,
1166
1375
  at,
1167
1376
  ...(previous ? { replaces: `silence:${flow.id}:${session.id}:${Date.parse(previous)}` } : {}),
1168
1377
  });
1169
1378
  }
1379
+ return wakes;
1170
1380
  }
1171
1381
 
1172
1382
  // ── Return (design §4.8) ──────────────────────────────────────────────