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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/README.md +5 -3
  2. package/dist/cjs/core/Agent.d.ts +8 -1
  3. package/dist/cjs/core/Agent.d.ts.map +1 -1
  4. package/dist/cjs/core/Agent.js +18 -0
  5. package/dist/cjs/core/Agent.js.map +1 -1
  6. package/dist/cjs/core/CompactionEngine.d.ts.map +1 -1
  7. package/dist/cjs/core/CompactionEngine.js +8 -3
  8. package/dist/cjs/core/CompactionEngine.js.map +1 -1
  9. package/dist/cjs/core/FlowSpec.d.ts +21 -2
  10. package/dist/cjs/core/FlowSpec.d.ts.map +1 -1
  11. package/dist/cjs/core/FlowSpec.js +324 -51
  12. package/dist/cjs/core/FlowSpec.js.map +1 -1
  13. package/dist/cjs/core/Migrate.d.ts.map +1 -1
  14. package/dist/cjs/core/Migrate.js +3 -1
  15. package/dist/cjs/core/Migrate.js.map +1 -1
  16. package/dist/cjs/core/Prompt.d.ts +16 -0
  17. package/dist/cjs/core/Prompt.d.ts.map +1 -1
  18. package/dist/cjs/core/Prompt.js +46 -1
  19. package/dist/cjs/core/Prompt.js.map +1 -1
  20. package/dist/cjs/core/Runner.d.ts +37 -4
  21. package/dist/cjs/core/Runner.d.ts.map +1 -1
  22. package/dist/cjs/core/Runner.js +269 -74
  23. package/dist/cjs/core/Runner.js.map +1 -1
  24. package/dist/cjs/core/Speak.d.ts.map +1 -1
  25. package/dist/cjs/core/Speak.js +50 -19
  26. package/dist/cjs/core/Speak.js.map +1 -1
  27. package/dist/cjs/core/Understand.d.ts +4 -3
  28. package/dist/cjs/core/Understand.d.ts.map +1 -1
  29. package/dist/cjs/core/Understand.js +22 -51
  30. package/dist/cjs/core/Understand.js.map +1 -1
  31. package/dist/cjs/core/contracts.d.ts +11 -6
  32. package/dist/cjs/core/contracts.d.ts.map +1 -1
  33. package/dist/cjs/index.d.ts +1 -1
  34. package/dist/cjs/index.d.ts.map +1 -1
  35. package/dist/cjs/persistence/OpenSearchStore.d.ts +2 -1
  36. package/dist/cjs/persistence/OpenSearchStore.d.ts.map +1 -1
  37. package/dist/cjs/persistence/OpenSearchStore.js +2 -2
  38. package/dist/cjs/persistence/OpenSearchStore.js.map +1 -1
  39. package/dist/cjs/persistence/RedisStore.d.ts +1 -1
  40. package/dist/cjs/persistence/RedisStore.d.ts.map +1 -1
  41. package/dist/cjs/providers/AnthropicProvider.d.ts +2 -5
  42. package/dist/cjs/providers/AnthropicProvider.d.ts.map +1 -1
  43. package/dist/cjs/providers/AnthropicProvider.js +3 -4
  44. package/dist/cjs/providers/AnthropicProvider.js.map +1 -1
  45. package/dist/cjs/providers/DeepSeekProvider.d.ts.map +1 -1
  46. package/dist/cjs/providers/DeepSeekProvider.js +3 -4
  47. package/dist/cjs/providers/DeepSeekProvider.js.map +1 -1
  48. package/dist/cjs/providers/FallbackAiProvider.js +1 -1
  49. package/dist/cjs/providers/FallbackAiProvider.js.map +1 -1
  50. package/dist/cjs/providers/GeminiProvider.d.ts +1 -2
  51. package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
  52. package/dist/cjs/providers/GeminiProvider.js +3 -4
  53. package/dist/cjs/providers/GeminiProvider.js.map +1 -1
  54. package/dist/cjs/providers/GenericOpenAICompatibleProvider.js +4 -4
  55. package/dist/cjs/providers/GenericOpenAICompatibleProvider.js.map +1 -1
  56. package/dist/cjs/providers/OpenAIProvider.d.ts.map +1 -1
  57. package/dist/cjs/providers/OpenAIProvider.js +3 -4
  58. package/dist/cjs/providers/OpenAIProvider.js.map +1 -1
  59. package/dist/cjs/providers/OpenRouterProvider.d.ts.map +1 -1
  60. package/dist/cjs/providers/OpenRouterProvider.js +4 -3
  61. package/dist/cjs/providers/OpenRouterProvider.js.map +1 -1
  62. package/dist/cjs/providers/ProviderAdapter.d.ts +15 -10
  63. package/dist/cjs/providers/ProviderAdapter.d.ts.map +1 -1
  64. package/dist/cjs/providers/ProviderAdapter.js +8 -3
  65. package/dist/cjs/providers/ProviderAdapter.js.map +1 -1
  66. package/dist/cjs/providers/ZaiProvider.js +1 -1
  67. package/dist/cjs/providers/ZaiProvider.js.map +1 -1
  68. package/dist/cjs/types/agent.d.ts +19 -4
  69. package/dist/cjs/types/agent.d.ts.map +1 -1
  70. package/dist/cjs/types/ai.d.ts +4 -6
  71. package/dist/cjs/types/ai.d.ts.map +1 -1
  72. package/dist/cjs/types/compaction.d.ts +1 -0
  73. package/dist/cjs/types/compaction.d.ts.map +1 -1
  74. package/dist/cjs/types/errors.d.ts +4 -9
  75. package/dist/cjs/types/errors.d.ts.map +1 -1
  76. package/dist/cjs/types/errors.js +12 -12
  77. package/dist/cjs/types/errors.js.map +1 -1
  78. package/dist/cjs/types/flow.d.ts +15 -1
  79. package/dist/cjs/types/flow.d.ts.map +1 -1
  80. package/dist/cjs/types/history.d.ts +0 -7
  81. package/dist/cjs/types/history.d.ts.map +1 -1
  82. package/dist/cjs/types/index.d.ts +2 -2
  83. package/dist/cjs/types/index.d.ts.map +1 -1
  84. package/dist/cjs/types/session.d.ts +4 -2
  85. package/dist/cjs/types/session.d.ts.map +1 -1
  86. package/dist/cjs/utils/clock.js +1 -1
  87. package/dist/cjs/utils/clock.js.map +1 -1
  88. package/dist/cjs/utils/outcomes.d.ts +1 -0
  89. package/dist/cjs/utils/outcomes.d.ts.map +1 -1
  90. package/dist/cjs/utils/outcomes.js +1 -0
  91. package/dist/cjs/utils/outcomes.js.map +1 -1
  92. package/dist/cjs/utils/phrases.d.ts +25 -0
  93. package/dist/cjs/utils/phrases.d.ts.map +1 -0
  94. package/dist/cjs/utils/phrases.js +38 -0
  95. package/dist/cjs/utils/phrases.js.map +1 -0
  96. package/dist/cjs/utils/schema.d.ts +3 -12
  97. package/dist/cjs/utils/schema.d.ts.map +1 -1
  98. package/dist/cjs/utils/schema.js +3 -43
  99. package/dist/cjs/utils/schema.js.map +1 -1
  100. package/dist/cjs/utils/template.d.ts +8 -0
  101. package/dist/cjs/utils/template.d.ts.map +1 -1
  102. package/dist/cjs/utils/template.js +47 -3
  103. package/dist/cjs/utils/template.js.map +1 -1
  104. package/dist/core/Agent.d.ts +8 -1
  105. package/dist/core/Agent.d.ts.map +1 -1
  106. package/dist/core/Agent.js +19 -1
  107. package/dist/core/Agent.js.map +1 -1
  108. package/dist/core/CompactionEngine.d.ts.map +1 -1
  109. package/dist/core/CompactionEngine.js +8 -3
  110. package/dist/core/CompactionEngine.js.map +1 -1
  111. package/dist/core/FlowSpec.d.ts +21 -2
  112. package/dist/core/FlowSpec.d.ts.map +1 -1
  113. package/dist/core/FlowSpec.js +323 -52
  114. package/dist/core/FlowSpec.js.map +1 -1
  115. package/dist/core/Migrate.d.ts.map +1 -1
  116. package/dist/core/Migrate.js +3 -1
  117. package/dist/core/Migrate.js.map +1 -1
  118. package/dist/core/Prompt.d.ts +16 -0
  119. package/dist/core/Prompt.d.ts.map +1 -1
  120. package/dist/core/Prompt.js +44 -1
  121. package/dist/core/Prompt.js.map +1 -1
  122. package/dist/core/Runner.d.ts +37 -4
  123. package/dist/core/Runner.d.ts.map +1 -1
  124. package/dist/core/Runner.js +270 -75
  125. package/dist/core/Runner.js.map +1 -1
  126. package/dist/core/Speak.d.ts.map +1 -1
  127. package/dist/core/Speak.js +51 -20
  128. package/dist/core/Speak.js.map +1 -1
  129. package/dist/core/Understand.d.ts +4 -3
  130. package/dist/core/Understand.d.ts.map +1 -1
  131. package/dist/core/Understand.js +22 -51
  132. package/dist/core/Understand.js.map +1 -1
  133. package/dist/core/contracts.d.ts +11 -6
  134. package/dist/core/contracts.d.ts.map +1 -1
  135. package/dist/index.d.ts +1 -1
  136. package/dist/index.d.ts.map +1 -1
  137. package/dist/persistence/OpenSearchStore.d.ts +2 -1
  138. package/dist/persistence/OpenSearchStore.d.ts.map +1 -1
  139. package/dist/persistence/OpenSearchStore.js +2 -2
  140. package/dist/persistence/OpenSearchStore.js.map +1 -1
  141. package/dist/persistence/RedisStore.d.ts +1 -1
  142. package/dist/persistence/RedisStore.d.ts.map +1 -1
  143. package/dist/providers/AnthropicProvider.d.ts +2 -5
  144. package/dist/providers/AnthropicProvider.d.ts.map +1 -1
  145. package/dist/providers/AnthropicProvider.js +3 -4
  146. package/dist/providers/AnthropicProvider.js.map +1 -1
  147. package/dist/providers/DeepSeekProvider.d.ts.map +1 -1
  148. package/dist/providers/DeepSeekProvider.js +3 -4
  149. package/dist/providers/DeepSeekProvider.js.map +1 -1
  150. package/dist/providers/FallbackAiProvider.js +1 -1
  151. package/dist/providers/FallbackAiProvider.js.map +1 -1
  152. package/dist/providers/GeminiProvider.d.ts +1 -2
  153. package/dist/providers/GeminiProvider.d.ts.map +1 -1
  154. package/dist/providers/GeminiProvider.js +3 -4
  155. package/dist/providers/GeminiProvider.js.map +1 -1
  156. package/dist/providers/GenericOpenAICompatibleProvider.js +4 -4
  157. package/dist/providers/GenericOpenAICompatibleProvider.js.map +1 -1
  158. package/dist/providers/OpenAIProvider.d.ts.map +1 -1
  159. package/dist/providers/OpenAIProvider.js +3 -4
  160. package/dist/providers/OpenAIProvider.js.map +1 -1
  161. package/dist/providers/OpenRouterProvider.d.ts.map +1 -1
  162. package/dist/providers/OpenRouterProvider.js +4 -3
  163. package/dist/providers/OpenRouterProvider.js.map +1 -1
  164. package/dist/providers/ProviderAdapter.d.ts +15 -10
  165. package/dist/providers/ProviderAdapter.d.ts.map +1 -1
  166. package/dist/providers/ProviderAdapter.js +8 -3
  167. package/dist/providers/ProviderAdapter.js.map +1 -1
  168. package/dist/providers/ZaiProvider.js +1 -1
  169. package/dist/providers/ZaiProvider.js.map +1 -1
  170. package/dist/types/agent.d.ts +19 -4
  171. package/dist/types/agent.d.ts.map +1 -1
  172. package/dist/types/ai.d.ts +4 -6
  173. package/dist/types/ai.d.ts.map +1 -1
  174. package/dist/types/compaction.d.ts +1 -0
  175. package/dist/types/compaction.d.ts.map +1 -1
  176. package/dist/types/errors.d.ts +4 -9
  177. package/dist/types/errors.d.ts.map +1 -1
  178. package/dist/types/errors.js +12 -12
  179. package/dist/types/errors.js.map +1 -1
  180. package/dist/types/flow.d.ts +15 -1
  181. package/dist/types/flow.d.ts.map +1 -1
  182. package/dist/types/history.d.ts +0 -7
  183. package/dist/types/history.d.ts.map +1 -1
  184. package/dist/types/index.d.ts +2 -2
  185. package/dist/types/index.d.ts.map +1 -1
  186. package/dist/types/session.d.ts +4 -2
  187. package/dist/types/session.d.ts.map +1 -1
  188. package/dist/utils/clock.js +1 -1
  189. package/dist/utils/clock.js.map +1 -1
  190. package/dist/utils/outcomes.d.ts +1 -0
  191. package/dist/utils/outcomes.d.ts.map +1 -1
  192. package/dist/utils/outcomes.js +1 -0
  193. package/dist/utils/outcomes.js.map +1 -1
  194. package/dist/utils/phrases.d.ts +25 -0
  195. package/dist/utils/phrases.d.ts.map +1 -0
  196. package/dist/utils/phrases.js +35 -0
  197. package/dist/utils/phrases.js.map +1 -0
  198. package/dist/utils/schema.d.ts +3 -12
  199. package/dist/utils/schema.d.ts.map +1 -1
  200. package/dist/utils/schema.js +3 -42
  201. package/dist/utils/schema.js.map +1 -1
  202. package/dist/utils/template.d.ts +8 -0
  203. package/dist/utils/template.d.ts.map +1 -1
  204. package/dist/utils/template.js +47 -3
  205. package/dist/utils/template.js.map +1 -1
  206. package/docs/concepts/architecture.md +3 -3
  207. package/docs/concepts/collection.md +40 -5
  208. package/docs/concepts/pipeline.md +12 -10
  209. package/docs/concepts/runs-and-waits.md +2 -2
  210. package/docs/guides/actions-and-events.md +2 -2
  211. package/docs/guides/branching.md +4 -2
  212. package/docs/guides/compaction.md +2 -2
  213. package/docs/guides/conditions.md +1 -1
  214. package/docs/guides/error-handling.md +3 -1
  215. package/docs/guides/flow-control.md +4 -2
  216. package/docs/guides/persistence.md +2 -2
  217. package/docs/guides/testing.md +1 -1
  218. package/docs/guides/triggers.md +5 -5
  219. package/docs/migration/v3-to-v4.md +16 -11
  220. package/docs/reference/actions-events-conditions.md +2 -2
  221. package/docs/reference/agent.md +11 -7
  222. package/docs/reference/branches.md +1 -1
  223. package/docs/reference/errors.md +10 -7
  224. package/docs/reference/fields.md +5 -3
  225. package/docs/reference/flow-spec.md +36 -9
  226. package/docs/reference/flow.md +8 -3
  227. package/docs/reference/outcomes.md +3 -2
  228. package/docs/reference/providers.md +2 -0
  229. package/docs/reference/session.md +2 -0
  230. package/docs/reference/step.md +8 -5
  231. package/docs/reference/stores.md +5 -3
  232. package/docs/reference/trigger.md +24 -4
  233. package/docs/rfc/v4-one-flow.md +7 -5
  234. package/docs/start/01-install.md +5 -3
  235. package/docs/start/04-add-tools.md +1 -1
  236. package/docs/start/05-go-to-production.md +18 -1
  237. package/examples/05-branches.ts +1 -1
  238. package/examples/06-triggers-and-waits.ts +4 -3
  239. package/package.json +5 -3
  240. package/src/core/Agent.ts +23 -2
  241. package/src/core/CompactionEngine.ts +10 -3
  242. package/src/core/FlowSpec.ts +352 -60
  243. package/src/core/Migrate.ts +2 -1
  244. package/src/core/Prompt.ts +47 -1
  245. package/src/core/Runner.ts +266 -71
  246. package/src/core/Speak.ts +53 -19
  247. package/src/core/Understand.ts +17 -50
  248. package/src/core/contracts.ts +11 -4
  249. package/src/index.ts +1 -1
  250. package/src/persistence/OpenSearchStore.ts +3 -2
  251. package/src/persistence/RedisStore.ts +1 -1
  252. package/src/providers/AnthropicProvider.ts +4 -9
  253. package/src/providers/DeepSeekProvider.ts +2 -4
  254. package/src/providers/FallbackAiProvider.ts +1 -1
  255. package/src/providers/GeminiProvider.ts +3 -6
  256. package/src/providers/GenericOpenAICompatibleProvider.ts +4 -4
  257. package/src/providers/OpenAIProvider.ts +3 -4
  258. package/src/providers/OpenRouterProvider.ts +4 -2
  259. package/src/providers/ProviderAdapter.ts +18 -13
  260. package/src/providers/ZaiProvider.ts +1 -1
  261. package/src/types/agent.ts +20 -5
  262. package/src/types/ai.ts +4 -6
  263. package/src/types/compaction.ts +1 -0
  264. package/src/types/errors.ts +11 -12
  265. package/src/types/flow.ts +15 -1
  266. package/src/types/history.ts +0 -10
  267. package/src/types/index.ts +1 -1
  268. package/src/types/session.ts +4 -1
  269. package/src/utils/clock.ts +1 -1
  270. package/src/utils/outcomes.ts +1 -0
  271. package/src/utils/phrases.ts +40 -0
  272. package/src/utils/schema.ts +3 -48
  273. package/src/utils/template.ts +46 -3
  274. package/dist/cjs/providers/index.d.ts +0 -26
  275. package/dist/cjs/providers/index.d.ts.map +0 -1
  276. package/dist/cjs/providers/index.js +0 -30
  277. package/dist/cjs/providers/index.js.map +0 -1
  278. package/dist/cjs/utils/clone.d.ts +0 -8
  279. package/dist/cjs/utils/clone.d.ts.map +0 -1
  280. package/dist/cjs/utils/clone.js +0 -32
  281. package/dist/cjs/utils/clone.js.map +0 -1
  282. package/dist/cjs/utils/index.d.ts +0 -9
  283. package/dist/cjs/utils/index.d.ts.map +0 -1
  284. package/dist/cjs/utils/index.js +0 -29
  285. package/dist/cjs/utils/index.js.map +0 -1
  286. package/dist/providers/index.d.ts +0 -26
  287. package/dist/providers/index.d.ts.map +0 -1
  288. package/dist/providers/index.js +0 -17
  289. package/dist/providers/index.js.map +0 -1
  290. package/dist/utils/clone.d.ts +0 -8
  291. package/dist/utils/clone.d.ts.map +0 -1
  292. package/dist/utils/clone.js +0 -29
  293. package/dist/utils/clone.js.map +0 -1
  294. package/dist/utils/index.d.ts +0 -9
  295. package/dist/utils/index.d.ts.map +0 -1
  296. package/dist/utils/index.js +0 -9
  297. package/dist/utils/index.js.map +0 -1
  298. package/src/providers/index.ts +0 -38
  299. package/src/utils/clone.ts +0 -34
  300. package/src/utils/index.ts +0 -18
@@ -10,7 +10,6 @@
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.Runner = void 0;
13
- const clone_js_1 = require("../utils/clone.js");
14
13
  const duration_js_1 = require("../utils/duration.js");
15
14
  const outcomes_js_1 = require("../utils/outcomes.js");
16
15
  const schema_js_1 = require("../utils/schema.js");
@@ -67,6 +66,15 @@ function defaultRepeat(kind) {
67
66
  function talkKind(step) {
68
67
  return step.collect?.length ? "collect" : "prompt";
69
68
  }
69
+ /** Everything a flow collects: its own `collect`, then each talk step's, once each. */
70
+ function flowFields(flow) {
71
+ const fields = new Set(flow.collect);
72
+ for (const step of flow.steps)
73
+ if (isTalk(step))
74
+ for (const field of step.collect ?? [])
75
+ fields.add(field);
76
+ return [...fields];
77
+ }
70
78
  function kindOf(step) {
71
79
  if (!step)
72
80
  return "if";
@@ -103,16 +111,29 @@ class Runner {
103
111
  }
104
112
  // ── Load + Ingest ─────────────────────────────────────────────────────
105
113
  begin(input) {
106
- const now = (this.options.clock ?? (() => new Date()))();
114
+ const now = this.now();
115
+ const turn = this.open(input, describe(input, now.toISOString()), now);
116
+ if (turn.what.kind === "wake" && !input.session) {
117
+ this.ignore(turn, "no-session", turn.what.key);
118
+ return turn;
119
+ }
120
+ turn.ingesting = true;
121
+ this.ingest(turn);
122
+ turn.ingesting = false;
123
+ return turn;
124
+ }
125
+ now() {
126
+ return (this.options.clock ?? (() => new Date()))();
127
+ }
128
+ open(input, what, now) {
107
129
  const nowIso = now.toISOString();
108
- const what = describe(input, nowIso);
109
130
  const silenced = typeof input.silenced === "string" ? input.silenced : input.silenced?.reason;
110
- const turn = {
131
+ return {
111
132
  input,
112
133
  what,
113
134
  kind: what.kind,
114
135
  triggerKey: what.kind === "message" ? (what.id ?? what.at) : what.key,
115
- session: input.session ? (0, clone_js_1.cloneDeep)(input.session) : freshSession(input.sessionId),
136
+ session: input.session ? structuredClone(input.session) : freshSession(input.sessionId),
116
137
  original: input.session,
117
138
  now,
118
139
  nowIso,
@@ -120,6 +141,7 @@ class Runner {
120
141
  context: input.context,
121
142
  silenced,
122
143
  understandAllowed: typeof input.silenced === "object" && input.silenced.understand === true,
144
+ skipSilenced: typeof input.silenced === "object" && input.silenced.skip === true,
123
145
  ignored: false,
124
146
  messages: [],
125
147
  schedule: [],
@@ -136,14 +158,14 @@ class Runner {
136
158
  speakDone: false,
137
159
  queue: [],
138
160
  };
139
- if (what.kind === "wake" && !input.session) {
140
- this.ignore(turn, "no-session", what.key);
141
- return turn;
142
- }
143
- turn.ingesting = true;
144
- this.ingest(turn);
145
- turn.ingesting = false;
146
- return turn;
161
+ }
162
+ /** Every wake the session waits on: each parked run's own, then each silence flow's since the assistant last spoke. */
163
+ pendingWakes(input) {
164
+ // No input arrives, so nothing reads `what`: the inert wake shape only fills the field.
165
+ const turn = this.open({ ...input, sessionId: input.session.id }, { kind: "wake", key: "" }, this.now());
166
+ // `park` always sets both; the type leaves them optional, so a hand-built blob without them has no wake to give.
167
+ const parked = turn.session.runs.flatMap(({ status, waiting }) => status === "waiting" && waiting?.key && waiting.until ? [{ key: waiting.key, at: new Date(waiting.until) }] : []);
168
+ return [...parked, ...this.silenceWakes(turn)];
147
169
  }
148
170
  ingest(turn) {
149
171
  const { what, session } = turn;
@@ -224,6 +246,9 @@ class Runner {
224
246
  const branch = step.branches?.find((b) => "if" in b && this.holds(b.if, turn, run));
225
247
  const target = branch ? branch.then : step.else;
226
248
  this.outcome(turn, run, { kind: "wait", status: "ok", code: "replied", next: nextLabel(target) });
249
+ // The reply is this run's while it moves, so a stay it reaches answers it.
250
+ if (!turn.floorFromIngest)
251
+ turn.floorRunId = run.id;
227
252
  this.follow(turn, run, flow, step, target);
228
253
  this.takeFloor(turn, run);
229
254
  }
@@ -263,8 +288,10 @@ class Runner {
263
288
  /** `silence:<flowId>:<sessionId>:<lastAssistantAtMs>`, honoured only while the blob still shows that silence. */
264
289
  silenceWake(turn, key) {
265
290
  const rest = key.slice("silence:".length);
266
- const flowId = rest.slice(0, rest.indexOf(":"));
267
291
  const ms = Number(rest.slice(rest.lastIndexOf(":") + 1));
292
+ // A flow id may hold a ":" itself, so cut at the session id rather than at the first ":".
293
+ const cut = rest.lastIndexOf(`:${turn.session.id}:`);
294
+ const flowId = rest.slice(0, cut === -1 ? rest.indexOf(":") : cut);
268
295
  const { lastAssistantAt } = turn.session;
269
296
  const flow = this.flows.get(flowId);
270
297
  if (!flow) {
@@ -319,13 +346,9 @@ class Runner {
319
346
  };
320
347
  if (opts.trigger?.if && !this.holds(opts.trigger.if, turn, run))
321
348
  return null;
322
- const heldAt = this.claimAt(turn, dedupeKey);
323
- if (heldAt !== undefined) {
324
- if (typeof repeat === "string")
325
- return skip("already-claimed");
326
- if (turn.now.getTime() - Date.parse(heldAt) < (0, duration_js_1.parseDuration)(repeat.cooldown))
327
- return skip("cooldown");
328
- }
349
+ const blocked = this.repeatBlocks(turn, dedupeKey, repeat);
350
+ if (blocked)
351
+ return skip(blocked);
329
352
  if (hop >= MAX_HOP)
330
353
  return skip("hop-limit");
331
354
  const live = session.runs.find((r) => r.flowId === flow.id && r.anchor === anchor);
@@ -348,9 +371,11 @@ class Runner {
348
371
  }
349
372
  session.runs.push(run);
350
373
  turn.started.push({ runId: run.id, flowId: flow.id, anchor, dedupeKey });
351
- const after = opts.trigger && "event" in opts.trigger ? opts.trigger.after : undefined;
352
- if (after) {
353
- const at = this.snap(turn, new Date(turn.now.getTime() + (0, duration_js_1.parseDuration)(after)), opts.trigger && "event" in opts.trigger ? opts.trigger.businessHours : undefined);
374
+ const eventTrigger = opts.trigger && "event" in opts.trigger ? opts.trigger : undefined;
375
+ const after = eventTrigger?.after;
376
+ const at = this.snap(turn, new Date(turn.now.getTime() + (after ? (0, duration_js_1.parseDuration)(after) : 0)), eventTrigger?.businessHours);
377
+ // With no `after`, only a closed hour parks the start: inside working hours the snap returns now.
378
+ if (after || at.getTime() > turn.now.getTime()) {
354
379
  this.park(turn, run, { kind: "timer", key: `${run.id}:start:${at.getTime()}` }, at);
355
380
  this.outcome(turn, run, { kind: "wait", status: "waiting", code: "awaiting-trigger", until: at.toISOString() });
356
381
  }
@@ -372,12 +397,16 @@ class Runner {
372
397
  }
373
398
  repeatAllows(turn, flow, trigger, key) {
374
399
  const repeat = trigger.repeat ?? defaultRepeat(triggerKind(trigger));
375
- const heldAt = this.claimAt(turn, `${flow.id}:${this.anchorOf(turn, flow)}:${repeat === "always" ? key : ""}`);
400
+ return !this.repeatBlocks(turn, `${flow.id}:${this.anchorOf(turn, flow)}:${repeat === "always" ? key : ""}`, repeat);
401
+ }
402
+ /** Why the claim already on `dedupeKey` stops another start, or nothing when there is none or its cooldown has run out. */
403
+ repeatBlocks(turn, dedupeKey, repeat) {
404
+ const heldAt = this.claimAt(turn, dedupeKey);
376
405
  if (heldAt === undefined)
377
- return true;
406
+ return undefined;
378
407
  if (typeof repeat === "string")
379
- return false;
380
- return turn.now.getTime() - Date.parse(heldAt) >= (0, duration_js_1.parseDuration)(repeat.cooldown);
408
+ return "already-claimed";
409
+ return turn.now.getTime() - Date.parse(heldAt) < (0, duration_js_1.parseDuration)(repeat.cooldown) ? "cooldown" : undefined;
381
410
  }
382
411
  /** A run as it would be if started now, for trigger-level predicates. */
383
412
  draftRun(turn, flow, kind, key, payload) {
@@ -439,8 +468,7 @@ class Runner {
439
468
  const floorRun = this.floorRun(turn);
440
469
  const floorFlow = floorRun && this.flows.get(floorRun.flowId);
441
470
  const eligible = this.eligibleMessageFlows(turn);
442
- // Exactly one eligible message flow and no floor: it starts without scoring (S9).
443
- const messageFlows = !floorRun && eligible.length === 1 ? [] : eligible;
471
+ const messageFlows = !floorRun && this.startsUnscored(turn, eligible) ? [] : eligible;
444
472
  const mentionFlows = this.mentionFlows()
445
473
  .filter(({ flow, trigger }) => trigger.mention.length > 0 && this.repeatAllows(turn, flow, trigger, turn.triggerKey))
446
474
  .map(({ flow }) => flow);
@@ -454,18 +482,28 @@ class Runner {
454
482
  }
455
483
  const fields = {};
456
484
  const data = turn.session.data;
485
+ const harvestable = (field) => {
486
+ const def = this.options.fields[field];
487
+ return def !== undefined && !(0, schema_js_1.isKnown)(data[field]) && (0, schema_js_1.extractMode)(def) === "anywhere";
488
+ };
457
489
  for (const flow of [...(floorFlow ? [floorFlow] : []), ...eligible]) {
458
- for (const step of flow.steps) {
459
- if (!isTalk(step))
460
- continue;
461
- for (const field of step.collect ?? []) {
462
- const def = this.options.fields[field];
463
- if (def && !(0, schema_js_1.isKnown)(data[field]) && (0, schema_js_1.extractMode)(def) === "anywhere")
464
- fields[field] = def;
465
- }
466
- }
490
+ for (const field of flowFields(flow).filter(harvestable))
491
+ fields[field] = this.options.fields[field];
467
492
  }
468
- if (!messageFlows.length && !mentionFlows.length && !branches.length && !Object.keys(fields).length)
493
+ const judging = messageFlows.length > 0 || mentionFlows.length > 0 || branches.length > 0 || Object.keys(fields).length > 0;
494
+ // With nobody on the floor the catch-all may take this message, and an opening message often says the most. The
495
+ // 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.
496
+ // 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.
497
+ const fallback = floorRun ? undefined : this.messageFlows(turn, (list) => list.length === 0)[0];
498
+ if (fallback) {
499
+ const first = fallback.steps[0];
500
+ const free = new Set(first && isTalk(first) && first.question === undefined ? first.collect : []);
501
+ const own = flowFields(fallback).filter(harvestable);
502
+ if (judging || own.some((field) => !free.has(field)))
503
+ for (const field of own)
504
+ fields[field] = this.options.fields[field];
505
+ }
506
+ if (!judging && !Object.keys(fields).length)
469
507
  return null;
470
508
  return {
471
509
  text: turn.what.text,
@@ -494,6 +532,13 @@ class Runner {
494
532
  eligibleMessageFlows(turn) {
495
533
  return this.messageFlows(turn, (list) => list.length > 0);
496
534
  }
535
+ /**
536
+ * The lone eligible flow starts without a score only when a low score would have nowhere
537
+ * else to send the message: no catch-all passes and the idle speaker is silent (S9).
538
+ */
539
+ startsUnscored(turn, eligible) {
540
+ return eligible.length === 1 && this.options.idle === "silent" && this.messageFlows(turn, (list) => list.length === 0).length === 0;
541
+ }
497
542
  messageFlows(turn, accept) {
498
543
  const out = [];
499
544
  for (const flow of this.flows.values()) {
@@ -538,7 +583,7 @@ class Runner {
538
583
  if (top && top.score >= current + ROUTE_STICKY && top.score >= ROUTE_MIN)
539
584
  route = top.flow;
540
585
  }
541
- else if (eligible.length === 1) {
586
+ else if (this.startsUnscored(turn, eligible)) {
542
587
  route = eligible[0];
543
588
  }
544
589
  else {
@@ -567,6 +612,9 @@ class Runner {
567
612
  turn.floorRunId = run.id;
568
613
  }
569
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;
570
618
  for (const [field, raw] of Object.entries(understanding?.fields ?? {}))
571
619
  this.writeField(turn, field, raw);
572
620
  if (asker && turn.session.runs.includes(asker) && asker.status === "asking") {
@@ -577,7 +625,13 @@ class Runner {
577
625
  }
578
626
  }
579
627
  fireBranch(turn, run, flow, step, understanding) {
580
- 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;
581
635
  const index = (step.branches ?? []).findIndex(hit);
582
636
  if (index < 0)
583
637
  return;
@@ -607,10 +661,47 @@ class Runner {
607
661
  async advance(turn) {
608
662
  if (turn.ignored)
609
663
  return null;
610
- this.resumeSuspended(turn);
664
+ this.resume(turn);
611
665
  turn.queue = [...turn.session.runs];
612
666
  await this.drain(turn);
613
- 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.askFixed(turn, speaker)) {
680
+ turn.talk = undefined;
681
+ return null;
682
+ }
683
+ return speaker;
684
+ }
685
+ /**
686
+ * Send the step's fixed question when this is its first ask: every field it collects still unknown, none asked yet,
687
+ * and the run not staying (a staying run answers the lead). The question counts as one ask of each field. False when
688
+ * the AI should phrase the ask instead.
689
+ */
690
+ askFixed(turn, { run, step, pending }) {
691
+ const { question, collect = [] } = step;
692
+ if (question === undefined || run.staying)
693
+ return false;
694
+ if (pending.length !== collect.length || pending.some((field) => (run.asked[field] ?? 0) > 0))
695
+ return false;
696
+ const key = this.stepKey(run, step);
697
+ turn.messages.push({ text: (0, template_js_1.render)(question, this.scope(turn, run)), kind: "verbatim", afterMs: turn.afterMs, key, runId: run.id, stepId: step.id });
698
+ turn.afterMs = 0;
699
+ turn.spokeBy.add(run.id);
700
+ turn.spoke = true;
701
+ for (const field of pending)
702
+ run.asked[field] = (run.asked[field] ?? 0) + 1;
703
+ this.outcome(turn, run, { kind: "collect", status: "ok", key, code: "asked-fixed", stepId: step.id });
704
+ return true;
614
705
  }
615
706
  /**
616
707
  * Everything Speak needs for this turn's one speaker. Instructions are agent,
@@ -633,12 +724,16 @@ class Runner {
633
724
  const instructions = [...(this.options.instructions ?? []), ...own.instructions].filter((ins) => !ins.if || (0, predicate_js_1.evaluate)(ins.if, ctx, conditions));
634
725
  const all = this.options.tools ?? [];
635
726
  const allowed = own.tools;
727
+ // deferTalk leaves a `deferred` outcome as the step's last line; the retry wake re-runs the step without adding one.
728
+ const last = "idle" in talk ? undefined : talk.run.outcomes.at(-1);
729
+ const retry = !("idle" in talk) && last?.status === "deferred" && last.stepId === talk.step.id;
636
730
  return {
637
731
  talk,
638
- input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind },
732
+ input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind, ...(retry ? { retry } : {}) },
639
733
  context: turn.context,
640
734
  data: turn.session.data,
641
735
  history: this.historyOf(turn),
736
+ said: [...turn.messages],
642
737
  now: turn.now,
643
738
  instructions,
644
739
  tools: allowed ? all.filter((tool) => allowed.includes(tool.id)) : all,
@@ -681,6 +776,11 @@ class Runner {
681
776
  }
682
777
  if (!this.premiseHolds(turn, run, flow))
683
778
  return;
779
+ // The flow was edited off 'stay' after this run finished its steps: its new `onEnd` decides now.
780
+ if (run.staying && flow.onEnd !== "stay") {
781
+ this.finishFlow(turn, run, flow);
782
+ return;
783
+ }
684
784
  const resuming = run.status === "asking";
685
785
  run.status = "running";
686
786
  if (run.stepId === null) {
@@ -723,6 +823,11 @@ class Runner {
723
823
  this.endRun(turn, run, "skipped", "premise-changed");
724
824
  return ok;
725
825
  }
826
+ /** `silenced: { skip: true }`: the message this step would send is dropped, and the run takes `then` as if it had gone out. */
827
+ skipSilenced(turn, run, flow, step, kind) {
828
+ this.outcome(turn, run, { kind, status: "skipped", key: this.stepKey(run, step), code: "silenced", detail: turn.silenced, next: nextLabel(step.then) });
829
+ this.follow(turn, run, flow, step, step.then);
830
+ }
726
831
  async execute(turn, run, flow, step, resuming) {
727
832
  const data = turn.session.data;
728
833
  const key = this.stepKey(run, step);
@@ -731,7 +836,9 @@ class Runner {
731
836
  let pending = [];
732
837
  if (step.collect?.length) {
733
838
  pending = (0, schema_js_1.pendingFields)(step, data, run.asked);
734
- if (!pending.length) {
839
+ // A `stay` run answers even with nothing left to collect: answering is what it stays for. It only runs on a message
840
+ // (asking runs sit out wakes and events), and on a silenced one the check below keeps it asking.
841
+ if (!pending.length && !run.staying) {
735
842
  this.reportMaxAsks(turn, run, step);
736
843
  this.outcome(turn, run, resuming
737
844
  ? { kind, status: "ok", key, next: nextLabel(step.then) }
@@ -743,6 +850,8 @@ class Runner {
743
850
  if (turn.silenced !== undefined) {
744
851
  if (resuming)
745
852
  run.status = "asking"; // the gate is closed; it speaks when the gate opens
853
+ else if (turn.skipSilenced)
854
+ this.skipSilenced(turn, run, flow, step, kind);
746
855
  else
747
856
  this.endRun(turn, run, "skipped", "silenced", kind, turn.silenced);
748
857
  return;
@@ -754,13 +863,19 @@ class Runner {
754
863
  }
755
864
  }
756
865
  run.status = "asking";
866
+ // 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.
757
867
  if (!turn.speakDone)
758
868
  turn.talk = { run, flow, step, pending };
869
+ else
870
+ this.askFixed(turn, { run, flow, step, pending });
759
871
  return;
760
872
  }
761
873
  if (isSay(step)) {
762
874
  if (turn.silenced !== undefined) {
763
- this.endRun(turn, run, "skipped", "silenced", "say", turn.silenced);
875
+ if (turn.skipSilenced)
876
+ this.skipSilenced(turn, run, flow, step, "say");
877
+ else
878
+ this.endRun(turn, run, "skipped", "silenced", "say", turn.silenced);
764
879
  return;
765
880
  }
766
881
  if (step.once) {
@@ -807,6 +922,13 @@ class Runner {
807
922
  result = { failed: error instanceof Error ? error.message : String(error) };
808
923
  }
809
924
  }
925
+ // The handler already ran, so a defer that cannot be parsed must not throw
926
+ // the turn: every replay would repeat the side effect and fail again.
927
+ if ("defer" in result && !(0, duration_js_1.isDuration)(String(result.defer))) {
928
+ result = {
929
+ failed: `action "${step.do}" asked to defer by "${String(result.defer)}", which is not a duration. Use a value like "2m" or "1h".`,
930
+ };
931
+ }
810
932
  if ("ok" in result) {
811
933
  if (result.spoke) {
812
934
  turn.spokeBy.add(run.id);
@@ -898,18 +1020,21 @@ class Runner {
898
1020
  }
899
1021
  if ("step" in target) {
900
1022
  const data = turn.session.data;
901
- for (const field of target.clear ?? [])
1023
+ // Forgetting a field means asking for it from scratch: its ask count goes too, so a fixed question goes out again.
1024
+ for (const field of target.clear ?? []) {
902
1025
  delete data[field];
1026
+ delete run.asked[field];
1027
+ }
903
1028
  this.jump(turn, run, flow, target.step);
904
1029
  return;
905
1030
  }
906
1031
  const childId = (0, template_js_1.render)(target.flow, this.scope(turn, run));
907
1032
  const key = this.stepKey(run, step);
908
1033
  this.endRun(turn, run, "flow");
909
- this.chain(turn, childId, key, target.input ?? run.input, run.hop + 1);
1034
+ this.chain(turn, run, childId, key, target.input ?? run.input, run.hop + 1);
910
1035
  }
911
- /** Start a child flow that holds the floor and moves in this same phase. */
912
- chain(turn, flowId, key, input, hop, keepData = false) {
1036
+ /** Start a child flow that moves in this same phase. It inherits the floor when its parent held it, or when nobody did. */
1037
+ chain(turn, parent, flowId, key, input, hop, keepData = false) {
913
1038
  const child = this.flows.get(flowId);
914
1039
  if (!child) {
915
1040
  turn.skipped.push({ flowId, anchor: turn.session.id, triggerKey: key, code: "flow-gone", message: outcomes_js_1.OUTCOME_MESSAGES["flow-gone"] });
@@ -918,9 +1043,11 @@ class Runner {
918
1043
  const run = this.startRun(turn, child, "flow", key, { payload: input, hop, keepData });
919
1044
  if (!run)
920
1045
  return;
921
- turn.floorRunId = run.id;
922
- if (turn.ingesting)
923
- turn.floorFromIngest = true;
1046
+ if (turn.floorRunId === undefined || turn.floorRunId === parent.id) {
1047
+ turn.floorRunId = run.id;
1048
+ if (turn.ingesting)
1049
+ turn.floorFromIngest = true;
1050
+ }
924
1051
  turn.queue.push(run);
925
1052
  }
926
1053
  jump(turn, run, flow, stepId) {
@@ -935,14 +1062,54 @@ class Runner {
935
1062
  run.visits[stepId] = (run.visits[stepId] ?? 0) + 1;
936
1063
  run.status = "running";
937
1064
  delete run.waiting;
1065
+ delete run.staying;
1066
+ }
1067
+ /** `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. */
1068
+ stayAt(run, step) {
1069
+ this.enter(run, step.id);
1070
+ run.staying = true;
1071
+ run.status = "asking";
1072
+ }
1073
+ /**
1074
+ * 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.
1075
+ * 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
1076
+ * stays on the flow's last talk step. Upgrade: record the talk step on the run when it talks.
1077
+ */
1078
+ stayStep(run, flow) {
1079
+ for (let i = run.outcomes.length - 1; i >= 0; i--) {
1080
+ const { kind, stepId } = run.outcomes[i];
1081
+ const step = kind === "prompt" || kind === "collect" ? this.stepOf(flow, stepId ?? null) : undefined;
1082
+ if (step && isTalk(step))
1083
+ return step;
1084
+ }
1085
+ return [...flow.steps].reverse().find(isTalk);
938
1086
  }
939
1087
  finishFlow(turn, run, flow) {
940
1088
  const onEnd = flow.onEnd ?? "end";
941
1089
  const last = flow.steps[flow.steps.length - 1];
942
- if (onEnd === "stay" && last) {
943
- // "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.
944
- this.enter(run, last.id);
945
- run.status = "asking";
1090
+ const stay = onEnd === "stay" ? this.stayStep(run, flow) : undefined;
1091
+ if (stay) {
1092
+ // The steps after it ran once, on the way here; staying, it only answers.
1093
+ this.stayAt(run, stay);
1094
+ // One asker at a time. A run the lead's message went to holds the conversation and suspends the other asker;
1095
+ // any other run waits behind that asker and resumes when it is done.
1096
+ const others = turn.session.runs.filter((r) => r !== run && r.status === "asking");
1097
+ if (others.length && !(turn.what.kind === "message" && turn.floorRunId === run.id)) {
1098
+ run.status = "suspended";
1099
+ run.suspendedAt = turn.nowIso;
1100
+ return;
1101
+ }
1102
+ for (const other of others) {
1103
+ other.status = "suspended";
1104
+ other.suspendedAt = turn.nowIso;
1105
+ if (turn.talk?.run === other)
1106
+ turn.talk = undefined;
1107
+ }
1108
+ // A message nothing has answered yet gets its answer now. Reached in Ingest, the run stays asking: Decide judges its
1109
+ // branches as the asker's, and it answers when it moves.
1110
+ if (!turn.ingesting && turn.what.kind === "message" && turn.silenced === undefined && !turn.speakDone && turn.spokeBy.size === 0) {
1111
+ run.status = "running";
1112
+ }
946
1113
  return;
947
1114
  }
948
1115
  if (onEnd === "reset" && last) {
@@ -950,7 +1117,7 @@ class Runner {
950
1117
  // 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.
951
1118
  const key = this.stepKey(run, last);
952
1119
  this.endRun(turn, run, "reset");
953
- this.chain(turn, flow.id, key, run.input, run.hop + 1, true);
1120
+ this.chain(turn, run, flow.id, key, run.input, run.hop + 1, true);
954
1121
  return;
955
1122
  }
956
1123
  this.endRun(turn, run, "end");
@@ -972,10 +1139,21 @@ class Runner {
972
1139
  run.waiting = { ...waiting, until: at.toISOString(), setAt: turn.nowIso };
973
1140
  turn.schedule.push({ key: waiting.key, at });
974
1141
  }
1142
+ /** `resumeSuspended`, and on a message the resumed run's `if` branches are judged before it speaks, as the asker's are in Decide. */
1143
+ resume(turn) {
1144
+ const run = this.resumeSuspended(turn);
1145
+ if (run && turn.what.kind === "message") {
1146
+ const flow = this.flows.get(run.flowId);
1147
+ const step = flow && this.stepOf(flow, run.stepId);
1148
+ if (flow && step && isTalk(step))
1149
+ this.fireBranch(turn, run, flow, step, null);
1150
+ }
1151
+ return run;
1152
+ }
975
1153
  /** When nobody asks, the most recently suspended run returns to asking. */
976
1154
  resumeSuspended(turn) {
977
1155
  if (this.asker(turn))
978
- return;
1156
+ return undefined;
979
1157
  const suspendedAt = (r) => r.suspendedAt ?? r.startedAt;
980
1158
  const next = turn.session.runs
981
1159
  .filter((r) => r.status === "suspended")
@@ -984,6 +1162,7 @@ class Runner {
984
1162
  next.status = "asking";
985
1163
  delete next.suspendedAt;
986
1164
  }
1165
+ return next;
987
1166
  }
988
1167
  // ── Settle: the one applier after Speak (design §4.7) ─────────────────
989
1168
  async settle(turn, outcome) {
@@ -1038,16 +1217,24 @@ class Runner {
1038
1217
  this.outcome(turn, run, { kind: talkKind(step), status: "ok", key, llmCalls: spoken.llmCalls, stepId: step.id });
1039
1218
  if (!turn.session.runs.includes(run))
1040
1219
  return;
1041
- if (step.collect?.length) {
1042
- const pending = (0, schema_js_1.pendingFields)(step, turn.session.data, run.asked);
1043
- if (pending.length) {
1044
- for (const field of pending)
1045
- run.asked[field] = (run.asked[field] ?? 0) + 1;
1046
- run.status = "asking";
1047
- return;
1220
+ const pending = step.collect?.length ? (0, schema_js_1.pendingFields)(step, turn.session.data, run.asked) : [];
1221
+ for (const field of pending)
1222
+ run.asked[field] = (run.asked[field] ?? 0) + 1;
1223
+ if (run.staying) {
1224
+ // 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.
1225
+ const maxAsks = step.maxAsks ?? schema_js_1.DEFAULT_MAX_ASKS;
1226
+ for (const field of pending) {
1227
+ if (run.asked[field] === maxAsks)
1228
+ this.outcome(turn, run, { kind: "collect", status: "skipped", key, code: "max-asks", detail: field });
1048
1229
  }
1049
- this.reportMaxAsks(turn, run, step);
1230
+ this.stayAt(run, step);
1231
+ return;
1232
+ }
1233
+ if (pending.length) {
1234
+ run.status = "asking";
1235
+ return;
1050
1236
  }
1237
+ this.reportMaxAsks(turn, run, step);
1051
1238
  run.status = "running";
1052
1239
  this.follow(turn, run, flow, step, step.then);
1053
1240
  if (turn.session.runs.includes(run) && run.status === "running")
@@ -1084,16 +1271,23 @@ class Runner {
1084
1271
  const reset = deferred.resetAtMs;
1085
1272
  return new Date(reset !== undefined && reset > ladder ? reset : ladder);
1086
1273
  }
1087
- /** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
1274
+ /** The assistant spoke this turn: its silence wakes replace the ones its last words set. */
1088
1275
  armSilence(turn) {
1276
+ const previous = turn.original?.lastAssistantAt;
1277
+ if (turn.session.lastAssistantAt === previous)
1278
+ return;
1279
+ turn.schedule.push(...this.silenceWakes(turn, previous));
1280
+ }
1281
+ /** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
1282
+ silenceWakes(turn, previous) {
1089
1283
  const { session } = turn;
1090
1284
  const { lastAssistantAt, lastUserAt } = session;
1091
- if (!lastAssistantAt || lastAssistantAt === turn.original?.lastAssistantAt)
1092
- return;
1285
+ if (!lastAssistantAt)
1286
+ return [];
1093
1287
  if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt))
1094
- return;
1288
+ return [];
1095
1289
  const ms = Date.parse(lastAssistantAt);
1096
- const previous = turn.original?.lastAssistantAt;
1290
+ const wakes = [];
1097
1291
  for (const flow of this.flows.values()) {
1098
1292
  const trigger = (flow.on ?? []).find((t) => "silence" in t);
1099
1293
  if (!trigger)
@@ -1104,12 +1298,13 @@ class Runner {
1104
1298
  if (!this.repeatAllows(turn, flow, trigger, key))
1105
1299
  continue;
1106
1300
  const at = this.snap(turn, new Date(ms + (0, duration_js_1.parseDuration)(trigger.silence)), trigger.businessHours);
1107
- turn.schedule.push({
1301
+ wakes.push({
1108
1302
  key: `silence:${flow.id}:${session.id}:${ms}`,
1109
1303
  at,
1110
1304
  ...(previous ? { replaces: `silence:${flow.id}:${session.id}:${Date.parse(previous)}` } : {}),
1111
1305
  });
1112
1306
  }
1307
+ return wakes;
1113
1308
  }
1114
1309
  // ── Return (design §4.8) ──────────────────────────────────────────────
1115
1310
  finish(turn) {