@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
@@ -7,8 +7,7 @@
7
7
  *
8
8
  * begin ─▶ understandRequest ─(Understand)─▶ decide ─▶ advance ─(Speak)─▶ settle ─▶ finish
9
9
  */
10
- import { cloneDeep } from "../utils/clone.js";
11
- import { parseDuration } from "../utils/duration.js";
10
+ import { isDuration, parseDuration } from "../utils/duration.js";
12
11
  import { OUTCOME_MESSAGES } from "../utils/outcomes.js";
13
12
  import { coerceField, DEFAULT_MAX_ASKS, extractMode, isKnown, pendingFields } from "../utils/schema.js";
14
13
  import { render, renderDeep } from "../utils/template.js";
@@ -64,6 +63,15 @@ function defaultRepeat(kind) {
64
63
  function talkKind(step) {
65
64
  return step.collect?.length ? "collect" : "prompt";
66
65
  }
66
+ /** Everything a flow collects: its own `collect`, then each talk step's, once each. */
67
+ function flowFields(flow) {
68
+ const fields = new Set(flow.collect);
69
+ for (const step of flow.steps)
70
+ if (isTalk(step))
71
+ for (const field of step.collect ?? [])
72
+ fields.add(field);
73
+ return [...fields];
74
+ }
67
75
  function kindOf(step) {
68
76
  if (!step)
69
77
  return "if";
@@ -100,16 +108,29 @@ export class Runner {
100
108
  }
101
109
  // ── Load + Ingest ─────────────────────────────────────────────────────
102
110
  begin(input) {
103
- const now = (this.options.clock ?? (() => new Date()))();
111
+ const now = this.now();
112
+ const turn = this.open(input, describe(input, now.toISOString()), now);
113
+ if (turn.what.kind === "wake" && !input.session) {
114
+ this.ignore(turn, "no-session", turn.what.key);
115
+ return turn;
116
+ }
117
+ turn.ingesting = true;
118
+ this.ingest(turn);
119
+ turn.ingesting = false;
120
+ return turn;
121
+ }
122
+ now() {
123
+ return (this.options.clock ?? (() => new Date()))();
124
+ }
125
+ open(input, what, now) {
104
126
  const nowIso = now.toISOString();
105
- const what = describe(input, nowIso);
106
127
  const silenced = typeof input.silenced === "string" ? input.silenced : input.silenced?.reason;
107
- const turn = {
128
+ return {
108
129
  input,
109
130
  what,
110
131
  kind: what.kind,
111
132
  triggerKey: what.kind === "message" ? (what.id ?? what.at) : what.key,
112
- session: input.session ? cloneDeep(input.session) : freshSession(input.sessionId),
133
+ session: input.session ? structuredClone(input.session) : freshSession(input.sessionId),
113
134
  original: input.session,
114
135
  now,
115
136
  nowIso,
@@ -117,6 +138,7 @@ export class Runner {
117
138
  context: input.context,
118
139
  silenced,
119
140
  understandAllowed: typeof input.silenced === "object" && input.silenced.understand === true,
141
+ skipSilenced: typeof input.silenced === "object" && input.silenced.skip === true,
120
142
  ignored: false,
121
143
  messages: [],
122
144
  schedule: [],
@@ -133,14 +155,14 @@ export class Runner {
133
155
  speakDone: false,
134
156
  queue: [],
135
157
  };
136
- if (what.kind === "wake" && !input.session) {
137
- this.ignore(turn, "no-session", what.key);
138
- return turn;
139
- }
140
- turn.ingesting = true;
141
- this.ingest(turn);
142
- turn.ingesting = false;
143
- return turn;
158
+ }
159
+ /** Every wake the session waits on: each parked run's own, then each silence flow's since the assistant last spoke. */
160
+ pendingWakes(input) {
161
+ // No input arrives, so nothing reads `what`: the inert wake shape only fills the field.
162
+ const turn = this.open({ ...input, sessionId: input.session.id }, { kind: "wake", key: "" }, this.now());
163
+ // `park` always sets both; the type leaves them optional, so a hand-built blob without them has no wake to give.
164
+ const parked = turn.session.runs.flatMap(({ status, waiting }) => status === "waiting" && waiting?.key && waiting.until ? [{ key: waiting.key, at: new Date(waiting.until) }] : []);
165
+ return [...parked, ...this.silenceWakes(turn)];
144
166
  }
145
167
  ingest(turn) {
146
168
  const { what, session } = turn;
@@ -221,6 +243,9 @@ export class Runner {
221
243
  const branch = step.branches?.find((b) => "if" in b && this.holds(b.if, turn, run));
222
244
  const target = branch ? branch.then : step.else;
223
245
  this.outcome(turn, run, { kind: "wait", status: "ok", code: "replied", next: nextLabel(target) });
246
+ // The reply is this run's while it moves, so a stay it reaches answers it.
247
+ if (!turn.floorFromIngest)
248
+ turn.floorRunId = run.id;
224
249
  this.follow(turn, run, flow, step, target);
225
250
  this.takeFloor(turn, run);
226
251
  }
@@ -260,8 +285,10 @@ export class Runner {
260
285
  /** `silence:<flowId>:<sessionId>:<lastAssistantAtMs>`, honoured only while the blob still shows that silence. */
261
286
  silenceWake(turn, key) {
262
287
  const rest = key.slice("silence:".length);
263
- const flowId = rest.slice(0, rest.indexOf(":"));
264
288
  const ms = Number(rest.slice(rest.lastIndexOf(":") + 1));
289
+ // A flow id may hold a ":" itself, so cut at the session id rather than at the first ":".
290
+ const cut = rest.lastIndexOf(`:${turn.session.id}:`);
291
+ const flowId = rest.slice(0, cut === -1 ? rest.indexOf(":") : cut);
265
292
  const { lastAssistantAt } = turn.session;
266
293
  const flow = this.flows.get(flowId);
267
294
  if (!flow) {
@@ -316,13 +343,9 @@ export class Runner {
316
343
  };
317
344
  if (opts.trigger?.if && !this.holds(opts.trigger.if, turn, run))
318
345
  return null;
319
- const heldAt = this.claimAt(turn, dedupeKey);
320
- if (heldAt !== undefined) {
321
- if (typeof repeat === "string")
322
- return skip("already-claimed");
323
- if (turn.now.getTime() - Date.parse(heldAt) < parseDuration(repeat.cooldown))
324
- return skip("cooldown");
325
- }
346
+ const blocked = this.repeatBlocks(turn, dedupeKey, repeat);
347
+ if (blocked)
348
+ return skip(blocked);
326
349
  if (hop >= MAX_HOP)
327
350
  return skip("hop-limit");
328
351
  const live = session.runs.find((r) => r.flowId === flow.id && r.anchor === anchor);
@@ -345,9 +368,11 @@ export class Runner {
345
368
  }
346
369
  session.runs.push(run);
347
370
  turn.started.push({ runId: run.id, flowId: flow.id, anchor, dedupeKey });
348
- const after = opts.trigger && "event" in opts.trigger ? opts.trigger.after : undefined;
349
- if (after) {
350
- const at = this.snap(turn, new Date(turn.now.getTime() + parseDuration(after)), opts.trigger && "event" in opts.trigger ? opts.trigger.businessHours : undefined);
371
+ const eventTrigger = opts.trigger && "event" in opts.trigger ? opts.trigger : undefined;
372
+ const after = eventTrigger?.after;
373
+ const at = this.snap(turn, new Date(turn.now.getTime() + (after ? parseDuration(after) : 0)), eventTrigger?.businessHours);
374
+ // With no `after`, only a closed hour parks the start: inside working hours the snap returns now.
375
+ if (after || at.getTime() > turn.now.getTime()) {
351
376
  this.park(turn, run, { kind: "timer", key: `${run.id}:start:${at.getTime()}` }, at);
352
377
  this.outcome(turn, run, { kind: "wait", status: "waiting", code: "awaiting-trigger", until: at.toISOString() });
353
378
  }
@@ -369,12 +394,16 @@ export class Runner {
369
394
  }
370
395
  repeatAllows(turn, flow, trigger, key) {
371
396
  const repeat = trigger.repeat ?? defaultRepeat(triggerKind(trigger));
372
- const heldAt = this.claimAt(turn, `${flow.id}:${this.anchorOf(turn, flow)}:${repeat === "always" ? key : ""}`);
397
+ return !this.repeatBlocks(turn, `${flow.id}:${this.anchorOf(turn, flow)}:${repeat === "always" ? key : ""}`, repeat);
398
+ }
399
+ /** Why the claim already on `dedupeKey` stops another start, or nothing when there is none or its cooldown has run out. */
400
+ repeatBlocks(turn, dedupeKey, repeat) {
401
+ const heldAt = this.claimAt(turn, dedupeKey);
373
402
  if (heldAt === undefined)
374
- return true;
403
+ return undefined;
375
404
  if (typeof repeat === "string")
376
- return false;
377
- return turn.now.getTime() - Date.parse(heldAt) >= parseDuration(repeat.cooldown);
405
+ return "already-claimed";
406
+ return turn.now.getTime() - Date.parse(heldAt) < parseDuration(repeat.cooldown) ? "cooldown" : undefined;
378
407
  }
379
408
  /** A run as it would be if started now, for trigger-level predicates. */
380
409
  draftRun(turn, flow, kind, key, payload) {
@@ -450,20 +479,31 @@ export class Runner {
450
479
  }
451
480
  const fields = {};
452
481
  const data = turn.session.data;
482
+ const harvestable = (field) => {
483
+ const def = this.options.fields[field];
484
+ return def !== undefined && !isKnown(data[field]) && extractMode(def) === "anywhere";
485
+ };
453
486
  for (const flow of [...(floorFlow ? [floorFlow] : []), ...eligible]) {
454
- for (const step of flow.steps) {
455
- if (!isTalk(step))
456
- continue;
457
- for (const field of step.collect ?? []) {
458
- const def = this.options.fields[field];
459
- if (def && !isKnown(data[field]) && extractMode(def) === "anywhere")
460
- fields[field] = def;
461
- }
462
- }
487
+ for (const field of flowFields(flow).filter(harvestable))
488
+ fields[field] = this.options.fields[field];
463
489
  }
464
- if (!messageFlows.length && !mentionFlows.length && !branches.length && !Object.keys(fields).length)
490
+ const judging = messageFlows.length > 0 || mentionFlows.length > 0 || branches.length > 0 || Object.keys(fields).length > 0;
491
+ // With nobody on the floor the catch-all may take this message, and an opening message often says the most. The
492
+ // fields its first step asks ride on that step's speak call, so alone they spend no call; a fixed question has no speak call.
493
+ // ponytail: "first step" is steps[0]; a catch-all that opens with a say or an if pays the call for that step's fields too.
494
+ const fallback = floorRun ? undefined : this.messageFlows(turn, (list) => list.length === 0)[0];
495
+ if (fallback) {
496
+ const first = fallback.steps[0];
497
+ const free = new Set(first && isTalk(first) && first.question === undefined ? first.collect : []);
498
+ const own = flowFields(fallback).filter(harvestable);
499
+ if (judging || own.some((field) => !free.has(field)))
500
+ for (const field of own)
501
+ fields[field] = this.options.fields[field];
502
+ }
503
+ if (!judging && !Object.keys(fields).length)
465
504
  return null;
466
505
  return {
506
+ sessionId: turn.session.id,
467
507
  text: turn.what.text,
468
508
  history: this.historyOf(turn),
469
509
  context: turn.context,
@@ -527,6 +567,8 @@ export class Runner {
527
567
  return;
528
568
  turn.llmCalls += understanding?.llmCalls ?? 0;
529
569
  turn.usage = addUsage(turn.usage, understanding?.usage);
570
+ if (understanding?.asks !== undefined)
571
+ turn.asks = understanding.asks;
530
572
  const key = turn.triggerKey;
531
573
  const asker = this.asker(turn);
532
574
  // Routing is decided before extraction lands so `clearOnStart` runs first, and applied after mention runs.
@@ -570,6 +612,9 @@ export class Runner {
570
612
  turn.floorRunId = run.id;
571
613
  }
572
614
  }
615
+ // Unless routing or Ingest moved it, the message is the asker's: written down, so a chain beside it cannot take it.
616
+ if (asker && !route && !turn.floorFromIngest)
617
+ turn.floorRunId = asker.id;
573
618
  for (const [field, raw] of Object.entries(understanding?.fields ?? {}))
574
619
  this.writeField(turn, field, raw);
575
620
  if (asker && turn.session.runs.includes(asker) && asker.status === "asking") {
@@ -580,7 +625,13 @@ export class Runner {
580
625
  }
581
626
  }
582
627
  fireBranch(turn, run, flow, step, understanding) {
583
- const hit = (branch, index) => "if" in branch ? this.holds(branch.if, turn, run) : understanding?.branches[`${run.id}/${step.id}/${index}`] === true;
628
+ // Staying, a run does not take an `if` branch back onto a path it has already run: the fact it tests is still true,
629
+ // so it would re-run that path on every message. A `when` branch is judged on each new message and always counts.
630
+ // 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`.
631
+ const ran = (then) => typeof then === "string" ? then === "end" || (run.visits[then] ?? 0) > 0 : "step" in then && (run.visits[then.step] ?? 0) > 0;
632
+ const hit = (branch, index) => "if" in branch
633
+ ? !(run.staying && ran(branch.then)) && this.holds(branch.if, turn, run)
634
+ : understanding?.branches[`${run.id}/${step.id}/${index}`] === true;
584
635
  const index = (step.branches ?? []).findIndex(hit);
585
636
  if (index < 0)
586
637
  return;
@@ -610,10 +661,64 @@ export class Runner {
610
661
  async advance(turn) {
611
662
  if (turn.ignored)
612
663
  return null;
613
- this.resumeSuspended(turn);
664
+ this.resume(turn);
614
665
  turn.queue = [...turn.session.runs];
615
666
  await this.drain(turn);
616
- return this.speaker(turn);
667
+ // An asker that moved on without a word hands the message to the run it suspended, and that one to the next.
668
+ // Each pass takes a run off the suspended stack, so the stack's size bounds the loop.
669
+ for (let left = turn.session.runs.length; left > 0; left--) {
670
+ if (turn.what.kind !== "message" || turn.silenced !== undefined || turn.talk || turn.spokeBy.size > 0)
671
+ break;
672
+ const resumed = this.resume(turn);
673
+ if (!resumed)
674
+ break;
675
+ turn.queue.push(resumed);
676
+ await this.drain(turn);
677
+ }
678
+ const speaker = this.speaker(turn);
679
+ if (!speaker || "idle" in speaker || !this.fixedDue(speaker))
680
+ return speaker;
681
+ // A question the lead asked gets its answer: the step speaks once, answering it and asking the fixed question.
682
+ // Appending the text word for word after an answer could repeat or contradict it. Unjudged counts as asked,
683
+ // and so does the retry of a failed answer: a question sent over the lead's own drops it.
684
+ const answerFirst = turn.what.kind === "message" ? turn.asks !== false : this.retrying(speaker);
685
+ if (answerFirst && speaker.step.question !== undefined) {
686
+ speaker.fixedQuestion = render(speaker.step.question, this.scope(turn, speaker.run));
687
+ return speaker;
688
+ }
689
+ this.askFixed(turn, speaker);
690
+ turn.talk = undefined;
691
+ return null;
692
+ }
693
+ /** The step failed at the provider and runs again on its retry wake: deferTalk left `deferred` as its last line. */
694
+ retrying({ run, step }) {
695
+ const last = run.outcomes.at(-1);
696
+ return last?.status === "deferred" && last.stepId === step.id;
697
+ }
698
+ /**
699
+ * The step's fixed question is due: this is its first ask, with every field it collects still unknown and none asked
700
+ * yet, and the run is not staying (a staying run answers the lead). Otherwise the AI phrases the ask.
701
+ */
702
+ fixedDue({ run, step, pending }) {
703
+ const { question, collect = [] } = step;
704
+ if (question === undefined || run.staying)
705
+ return false;
706
+ return pending.length === collect.length && pending.every((field) => (run.asked[field] ?? 0) === 0);
707
+ }
708
+ /** Send the step's fixed question when it is due. It counts as one ask of each field. */
709
+ askFixed(turn, talk) {
710
+ const { run, step, pending } = talk;
711
+ if (!this.fixedDue(talk) || step.question === undefined)
712
+ return false;
713
+ const key = this.stepKey(run, step);
714
+ turn.messages.push({ text: render(step.question, this.scope(turn, run)), kind: "verbatim", afterMs: turn.afterMs, key, runId: run.id, stepId: step.id });
715
+ turn.afterMs = 0;
716
+ turn.spokeBy.add(run.id);
717
+ turn.spoke = true;
718
+ for (const field of pending)
719
+ run.asked[field] = (run.asked[field] ?? 0) + 1;
720
+ this.outcome(turn, run, { kind: "collect", status: "ok", key, code: "asked-fixed", stepId: step.id });
721
+ return true;
617
722
  }
618
723
  /**
619
724
  * Everything Speak needs for this turn's one speaker. Instructions are agent,
@@ -636,12 +741,15 @@ export class Runner {
636
741
  const instructions = [...(this.options.instructions ?? []), ...own.instructions].filter((ins) => !ins.if || evaluate(ins.if, ctx, conditions));
637
742
  const all = this.options.tools ?? [];
638
743
  const allowed = own.tools;
744
+ const retry = !("idle" in talk) && this.retrying(talk);
639
745
  return {
746
+ sessionId: turn.session.id,
640
747
  talk,
641
- input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind },
748
+ input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind, ...(retry ? { retry } : {}) },
642
749
  context: turn.context,
643
750
  data: turn.session.data,
644
751
  history: this.historyOf(turn),
752
+ said: [...turn.messages],
645
753
  now: turn.now,
646
754
  instructions,
647
755
  tools: allowed ? all.filter((tool) => allowed.includes(tool.id)) : all,
@@ -684,6 +792,11 @@ export class Runner {
684
792
  }
685
793
  if (!this.premiseHolds(turn, run, flow))
686
794
  return;
795
+ // The flow was edited off 'stay' after this run finished its steps: its new `onEnd` decides now.
796
+ if (run.staying && flow.onEnd !== "stay") {
797
+ this.finishFlow(turn, run, flow);
798
+ return;
799
+ }
687
800
  const resuming = run.status === "asking";
688
801
  run.status = "running";
689
802
  if (run.stepId === null) {
@@ -726,6 +839,11 @@ export class Runner {
726
839
  this.endRun(turn, run, "skipped", "premise-changed");
727
840
  return ok;
728
841
  }
842
+ /** `silenced: { skip: true }`: the message this step would send is dropped, and the run takes `then` as if it had gone out. */
843
+ skipSilenced(turn, run, flow, step, kind) {
844
+ this.outcome(turn, run, { kind, status: "skipped", key: this.stepKey(run, step), code: "silenced", detail: turn.silenced, next: nextLabel(step.then) });
845
+ this.follow(turn, run, flow, step, step.then);
846
+ }
729
847
  async execute(turn, run, flow, step, resuming) {
730
848
  const data = turn.session.data;
731
849
  const key = this.stepKey(run, step);
@@ -734,7 +852,9 @@ export class Runner {
734
852
  let pending = [];
735
853
  if (step.collect?.length) {
736
854
  pending = pendingFields(step, data, run.asked);
737
- if (!pending.length) {
855
+ // A `stay` run answers even with nothing left to collect: answering is what it stays for. It only runs on a message
856
+ // (asking runs sit out wakes and events), and on a silenced one the check below keeps it asking.
857
+ if (!pending.length && !run.staying) {
738
858
  this.reportMaxAsks(turn, run, step);
739
859
  this.outcome(turn, run, resuming
740
860
  ? { kind, status: "ok", key, next: nextLabel(step.then) }
@@ -746,6 +866,8 @@ export class Runner {
746
866
  if (turn.silenced !== undefined) {
747
867
  if (resuming)
748
868
  run.status = "asking"; // the gate is closed; it speaks when the gate opens
869
+ else if (turn.skipSilenced)
870
+ this.skipSilenced(turn, run, flow, step, kind);
749
871
  else
750
872
  this.endRun(turn, run, "skipped", "silenced", kind, turn.silenced);
751
873
  return;
@@ -757,13 +879,19 @@ export class Runner {
757
879
  }
758
880
  }
759
881
  run.status = "asking";
882
+ // 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.
760
883
  if (!turn.speakDone)
761
884
  turn.talk = { run, flow, step, pending };
885
+ else
886
+ this.askFixed(turn, { run, flow, step, pending });
762
887
  return;
763
888
  }
764
889
  if (isSay(step)) {
765
890
  if (turn.silenced !== undefined) {
766
- this.endRun(turn, run, "skipped", "silenced", "say", turn.silenced);
891
+ if (turn.skipSilenced)
892
+ this.skipSilenced(turn, run, flow, step, "say");
893
+ else
894
+ this.endRun(turn, run, "skipped", "silenced", "say", turn.silenced);
767
895
  return;
768
896
  }
769
897
  if (step.once) {
@@ -810,6 +938,13 @@ export class Runner {
810
938
  result = { failed: error instanceof Error ? error.message : String(error) };
811
939
  }
812
940
  }
941
+ // The handler already ran, so a defer that cannot be parsed must not throw
942
+ // the turn: every replay would repeat the side effect and fail again.
943
+ if ("defer" in result && !isDuration(String(result.defer))) {
944
+ result = {
945
+ failed: `action "${step.do}" asked to defer by "${String(result.defer)}", which is not a duration. Use a value like "2m" or "1h".`,
946
+ };
947
+ }
813
948
  if ("ok" in result) {
814
949
  if (result.spoke) {
815
950
  turn.spokeBy.add(run.id);
@@ -901,18 +1036,21 @@ export class Runner {
901
1036
  }
902
1037
  if ("step" in target) {
903
1038
  const data = turn.session.data;
904
- for (const field of target.clear ?? [])
1039
+ // Forgetting a field means asking for it from scratch: its ask count goes too, so a fixed question goes out again.
1040
+ for (const field of target.clear ?? []) {
905
1041
  delete data[field];
1042
+ delete run.asked[field];
1043
+ }
906
1044
  this.jump(turn, run, flow, target.step);
907
1045
  return;
908
1046
  }
909
1047
  const childId = render(target.flow, this.scope(turn, run));
910
1048
  const key = this.stepKey(run, step);
911
1049
  this.endRun(turn, run, "flow");
912
- this.chain(turn, childId, key, target.input ?? run.input, run.hop + 1);
1050
+ this.chain(turn, run, childId, key, target.input ?? run.input, run.hop + 1);
913
1051
  }
914
- /** Start a child flow that holds the floor and moves in this same phase. */
915
- chain(turn, flowId, key, input, hop, keepData = false) {
1052
+ /** Start a child flow that moves in this same phase. It inherits the floor when its parent held it, or when nobody did. */
1053
+ chain(turn, parent, flowId, key, input, hop, keepData = false) {
916
1054
  const child = this.flows.get(flowId);
917
1055
  if (!child) {
918
1056
  turn.skipped.push({ flowId, anchor: turn.session.id, triggerKey: key, code: "flow-gone", message: OUTCOME_MESSAGES["flow-gone"] });
@@ -921,9 +1059,11 @@ export class Runner {
921
1059
  const run = this.startRun(turn, child, "flow", key, { payload: input, hop, keepData });
922
1060
  if (!run)
923
1061
  return;
924
- turn.floorRunId = run.id;
925
- if (turn.ingesting)
926
- turn.floorFromIngest = true;
1062
+ if (turn.floorRunId === undefined || turn.floorRunId === parent.id) {
1063
+ turn.floorRunId = run.id;
1064
+ if (turn.ingesting)
1065
+ turn.floorFromIngest = true;
1066
+ }
927
1067
  turn.queue.push(run);
928
1068
  }
929
1069
  jump(turn, run, flow, stepId) {
@@ -938,14 +1078,54 @@ export class Runner {
938
1078
  run.visits[stepId] = (run.visits[stepId] ?? 0) + 1;
939
1079
  run.status = "running";
940
1080
  delete run.waiting;
1081
+ delete run.staying;
1082
+ }
1083
+ /** `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. */
1084
+ stayAt(run, step) {
1085
+ this.enter(run, step.id);
1086
+ run.staying = true;
1087
+ run.status = "asking";
1088
+ }
1089
+ /**
1090
+ * 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.
1091
+ * 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
1092
+ * stays on the flow's last talk step. Upgrade: record the talk step on the run when it talks.
1093
+ */
1094
+ stayStep(run, flow) {
1095
+ for (let i = run.outcomes.length - 1; i >= 0; i--) {
1096
+ const { kind, stepId } = run.outcomes[i];
1097
+ const step = kind === "prompt" || kind === "collect" ? this.stepOf(flow, stepId ?? null) : undefined;
1098
+ if (step && isTalk(step))
1099
+ return step;
1100
+ }
1101
+ return [...flow.steps].reverse().find(isTalk);
941
1102
  }
942
1103
  finishFlow(turn, run, flow) {
943
1104
  const onEnd = flow.onEnd ?? "end";
944
1105
  const last = flow.steps[flow.steps.length - 1];
945
- if (onEnd === "stay" && last) {
946
- // "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.
947
- this.enter(run, last.id);
948
- run.status = "asking";
1106
+ const stay = onEnd === "stay" ? this.stayStep(run, flow) : undefined;
1107
+ if (stay) {
1108
+ // The steps after it ran once, on the way here; staying, it only answers.
1109
+ this.stayAt(run, stay);
1110
+ // One asker at a time. A run the lead's message went to holds the conversation and suspends the other asker;
1111
+ // any other run waits behind that asker and resumes when it is done.
1112
+ const others = turn.session.runs.filter((r) => r !== run && r.status === "asking");
1113
+ if (others.length && !(turn.what.kind === "message" && turn.floorRunId === run.id)) {
1114
+ run.status = "suspended";
1115
+ run.suspendedAt = turn.nowIso;
1116
+ return;
1117
+ }
1118
+ for (const other of others) {
1119
+ other.status = "suspended";
1120
+ other.suspendedAt = turn.nowIso;
1121
+ if (turn.talk?.run === other)
1122
+ turn.talk = undefined;
1123
+ }
1124
+ // A message nothing has answered yet gets its answer now. Reached in Ingest, the run stays asking: Decide judges its
1125
+ // branches as the asker's, and it answers when it moves.
1126
+ if (!turn.ingesting && turn.what.kind === "message" && turn.silenced === undefined && !turn.speakDone && turn.spokeBy.size === 0) {
1127
+ run.status = "running";
1128
+ }
949
1129
  return;
950
1130
  }
951
1131
  if (onEnd === "reset" && last) {
@@ -953,7 +1133,7 @@ export class Runner {
953
1133
  // 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.
954
1134
  const key = this.stepKey(run, last);
955
1135
  this.endRun(turn, run, "reset");
956
- this.chain(turn, flow.id, key, run.input, run.hop + 1, true);
1136
+ this.chain(turn, run, flow.id, key, run.input, run.hop + 1, true);
957
1137
  return;
958
1138
  }
959
1139
  this.endRun(turn, run, "end");
@@ -975,10 +1155,21 @@ export class Runner {
975
1155
  run.waiting = { ...waiting, until: at.toISOString(), setAt: turn.nowIso };
976
1156
  turn.schedule.push({ key: waiting.key, at });
977
1157
  }
1158
+ /** `resumeSuspended`, and on a message the resumed run's `if` branches are judged before it speaks, as the asker's are in Decide. */
1159
+ resume(turn) {
1160
+ const run = this.resumeSuspended(turn);
1161
+ if (run && turn.what.kind === "message") {
1162
+ const flow = this.flows.get(run.flowId);
1163
+ const step = flow && this.stepOf(flow, run.stepId);
1164
+ if (flow && step && isTalk(step))
1165
+ this.fireBranch(turn, run, flow, step, null);
1166
+ }
1167
+ return run;
1168
+ }
978
1169
  /** When nobody asks, the most recently suspended run returns to asking. */
979
1170
  resumeSuspended(turn) {
980
1171
  if (this.asker(turn))
981
- return;
1172
+ return undefined;
982
1173
  const suspendedAt = (r) => r.suspendedAt ?? r.startedAt;
983
1174
  const next = turn.session.runs
984
1175
  .filter((r) => r.status === "suspended")
@@ -987,6 +1178,7 @@ export class Runner {
987
1178
  next.status = "asking";
988
1179
  delete next.suspendedAt;
989
1180
  }
1181
+ return next;
990
1182
  }
991
1183
  // ── Settle: the one applier after Speak (design §4.7) ─────────────────
992
1184
  async settle(turn, outcome) {
@@ -1038,19 +1230,28 @@ export class Runner {
1038
1230
  for (const [field, raw] of Object.entries(spoken.fields))
1039
1231
  this.writeField(turn, field, raw);
1040
1232
  Object.assign(turn.session.data, spoken.data);
1041
- this.outcome(turn, run, { kind: talkKind(step), status: "ok", key, llmCalls: spoken.llmCalls, stepId: step.id });
1233
+ const fixed = talk.fixedQuestion !== undefined ? { code: "fixed-in-reply" } : {};
1234
+ this.outcome(turn, run, { kind: talkKind(step), status: "ok", key, llmCalls: spoken.llmCalls, stepId: step.id, ...fixed });
1042
1235
  if (!turn.session.runs.includes(run))
1043
1236
  return;
1044
- if (step.collect?.length) {
1045
- const pending = pendingFields(step, turn.session.data, run.asked);
1046
- if (pending.length) {
1047
- for (const field of pending)
1048
- run.asked[field] = (run.asked[field] ?? 0) + 1;
1049
- run.status = "asking";
1050
- return;
1237
+ const pending = step.collect?.length ? pendingFields(step, turn.session.data, run.asked) : [];
1238
+ for (const field of pending)
1239
+ run.asked[field] = (run.asked[field] ?? 0) + 1;
1240
+ if (run.staying) {
1241
+ // 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.
1242
+ const maxAsks = step.maxAsks ?? DEFAULT_MAX_ASKS;
1243
+ for (const field of pending) {
1244
+ if (run.asked[field] === maxAsks)
1245
+ this.outcome(turn, run, { kind: "collect", status: "skipped", key, code: "max-asks", detail: field });
1051
1246
  }
1052
- this.reportMaxAsks(turn, run, step);
1247
+ this.stayAt(run, step);
1248
+ return;
1249
+ }
1250
+ if (pending.length) {
1251
+ run.status = "asking";
1252
+ return;
1053
1253
  }
1254
+ this.reportMaxAsks(turn, run, step);
1054
1255
  run.status = "running";
1055
1256
  this.follow(turn, run, flow, step, step.then);
1056
1257
  if (turn.session.runs.includes(run) && run.status === "running")
@@ -1087,16 +1288,23 @@ export class Runner {
1087
1288
  const reset = deferred.resetAtMs;
1088
1289
  return new Date(reset !== undefined && reset > ladder ? reset : ladder);
1089
1290
  }
1090
- /** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
1291
+ /** The assistant spoke this turn: its silence wakes replace the ones its last words set. */
1091
1292
  armSilence(turn) {
1293
+ const previous = turn.original?.lastAssistantAt;
1294
+ if (turn.session.lastAssistantAt === previous)
1295
+ return;
1296
+ turn.schedule.push(...this.silenceWakes(turn, previous));
1297
+ }
1298
+ /** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
1299
+ silenceWakes(turn, previous) {
1092
1300
  const { session } = turn;
1093
1301
  const { lastAssistantAt, lastUserAt } = session;
1094
- if (!lastAssistantAt || lastAssistantAt === turn.original?.lastAssistantAt)
1095
- return;
1302
+ if (!lastAssistantAt)
1303
+ return [];
1096
1304
  if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt))
1097
- return;
1305
+ return [];
1098
1306
  const ms = Date.parse(lastAssistantAt);
1099
- const previous = turn.original?.lastAssistantAt;
1307
+ const wakes = [];
1100
1308
  for (const flow of this.flows.values()) {
1101
1309
  const trigger = (flow.on ?? []).find((t) => "silence" in t);
1102
1310
  if (!trigger)
@@ -1107,12 +1315,13 @@ export class Runner {
1107
1315
  if (!this.repeatAllows(turn, flow, trigger, key))
1108
1316
  continue;
1109
1317
  const at = this.snap(turn, new Date(ms + parseDuration(trigger.silence)), trigger.businessHours);
1110
- turn.schedule.push({
1318
+ wakes.push({
1111
1319
  key: `silence:${flow.id}:${session.id}:${ms}`,
1112
1320
  at,
1113
1321
  ...(previous ? { replaces: `silence:${flow.id}:${session.id}:${Date.parse(previous)}` } : {}),
1114
1322
  });
1115
1323
  }
1324
+ return wakes;
1116
1325
  }
1117
1326
  // ── Return (design §4.8) ──────────────────────────────────────────────
1118
1327
  finish(turn) {