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

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 +42 -4
  21. package/dist/cjs/core/Runner.d.ts.map +1 -1
  22. package/dist/cjs/core/Runner.js +292 -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 +60 -21
  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 +34 -55
  30. package/dist/cjs/core/Understand.js.map +1 -1
  31. package/dist/cjs/core/contracts.d.ts +21 -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 +16 -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 +42 -4
  123. package/dist/core/Runner.d.ts.map +1 -1
  124. package/dist/core/Runner.js +293 -73
  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 +61 -22
  128. package/dist/core/Speak.js.map +1 -1
  129. package/dist/core/Understand.d.ts +6 -3
  130. package/dist/core/Understand.d.ts.map +1 -1
  131. package/dist/core/Understand.js +34 -55
  132. package/dist/core/Understand.js.map +1 -1
  133. package/dist/core/contracts.d.ts +21 -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 +16 -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 +13 -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 +9 -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 +290 -69
  246. package/src/core/Speak.ts +64 -21
  247. package/src/core/Understand.ts +35 -55
  248. package/src/core/contracts.ts +21 -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 +16 -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];
492
+ }
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];
467
505
  }
468
- if (!messageFlows.length && !mentionFlows.length && !branches.length && !Object.keys(fields).length)
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()) {
@@ -524,6 +569,8 @@ class Runner {
524
569
  return;
525
570
  turn.llmCalls += understanding?.llmCalls ?? 0;
526
571
  turn.usage = (0, usage_js_1.addUsage)(turn.usage, understanding?.usage);
572
+ if (understanding?.asks !== undefined)
573
+ turn.asks = understanding.asks;
527
574
  const key = turn.triggerKey;
528
575
  const asker = this.asker(turn);
529
576
  // Routing is decided before extraction lands so `clearOnStart` runs first, and applied after mention runs.
@@ -538,7 +585,7 @@ class Runner {
538
585
  if (top && top.score >= current + ROUTE_STICKY && top.score >= ROUTE_MIN)
539
586
  route = top.flow;
540
587
  }
541
- else if (eligible.length === 1) {
588
+ else if (this.startsUnscored(turn, eligible)) {
542
589
  route = eligible[0];
543
590
  }
544
591
  else {
@@ -567,6 +614,9 @@ class Runner {
567
614
  turn.floorRunId = run.id;
568
615
  }
569
616
  }
617
+ // Unless routing or Ingest moved it, the message is the asker's: written down, so a chain beside it cannot take it.
618
+ if (asker && !route && !turn.floorFromIngest)
619
+ turn.floorRunId = asker.id;
570
620
  for (const [field, raw] of Object.entries(understanding?.fields ?? {}))
571
621
  this.writeField(turn, field, raw);
572
622
  if (asker && turn.session.runs.includes(asker) && asker.status === "asking") {
@@ -577,7 +627,13 @@ class Runner {
577
627
  }
578
628
  }
579
629
  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;
630
+ // Staying, a run does not take an `if` branch back onto a path it has already run: the fact it tests is still true,
631
+ // so it would re-run that path on every message. A `when` branch is judged on each new message and always counts.
632
+ // 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`.
633
+ const ran = (then) => typeof then === "string" ? then === "end" || (run.visits[then] ?? 0) > 0 : "step" in then && (run.visits[then.step] ?? 0) > 0;
634
+ const hit = (branch, index) => "if" in branch
635
+ ? !(run.staying && ran(branch.then)) && this.holds(branch.if, turn, run)
636
+ : understanding?.branches[`${run.id}/${step.id}/${index}`] === true;
581
637
  const index = (step.branches ?? []).findIndex(hit);
582
638
  if (index < 0)
583
639
  return;
@@ -607,10 +663,63 @@ class Runner {
607
663
  async advance(turn) {
608
664
  if (turn.ignored)
609
665
  return null;
610
- this.resumeSuspended(turn);
666
+ this.resume(turn);
611
667
  turn.queue = [...turn.session.runs];
612
668
  await this.drain(turn);
613
- return this.speaker(turn);
669
+ // An asker that moved on without a word hands the message to the run it suspended, and that one to the next.
670
+ // Each pass takes a run off the suspended stack, so the stack's size bounds the loop.
671
+ for (let left = turn.session.runs.length; left > 0; left--) {
672
+ if (turn.what.kind !== "message" || turn.silenced !== undefined || turn.talk || turn.spokeBy.size > 0)
673
+ break;
674
+ const resumed = this.resume(turn);
675
+ if (!resumed)
676
+ break;
677
+ turn.queue.push(resumed);
678
+ await this.drain(turn);
679
+ }
680
+ const speaker = this.speaker(turn);
681
+ if (!speaker || "idle" in speaker || !this.fixedDue(speaker))
682
+ return speaker;
683
+ // A question the lead asked gets its answer first: the step speaks, and its fixed question follows in settle.
684
+ // Unjudged counts as asked, and so does the retry of a failed answer: a question sent over the lead's own drops it.
685
+ const answerFirst = turn.what.kind === "message" ? turn.asks !== false : this.retrying(speaker);
686
+ if (answerFirst && speaker.step.question !== undefined) {
687
+ speaker.fixedAfter = (0, template_js_1.render)(speaker.step.question, this.scope(turn, speaker.run));
688
+ return speaker;
689
+ }
690
+ this.askFixed(turn, speaker);
691
+ turn.talk = undefined;
692
+ return null;
693
+ }
694
+ /** The step failed at the provider and runs again on its retry wake: deferTalk left `deferred` as its last line. */
695
+ retrying({ run, step }) {
696
+ const last = run.outcomes.at(-1);
697
+ return last?.status === "deferred" && last.stepId === step.id;
698
+ }
699
+ /**
700
+ * The step's fixed question is due: this is its first ask, with every field it collects still unknown and none asked
701
+ * yet, and the run is not staying (a staying run answers the lead). Otherwise the AI phrases the ask.
702
+ */
703
+ fixedDue({ run, step, pending }) {
704
+ const { question, collect = [] } = step;
705
+ if (question === undefined || run.staying)
706
+ return false;
707
+ return pending.length === collect.length && pending.every((field) => (run.asked[field] ?? 0) === 0);
708
+ }
709
+ /** Send the step's fixed question when it is due. It counts as one ask of each field. */
710
+ askFixed(turn, talk) {
711
+ const { run, step, pending } = talk;
712
+ if (!this.fixedDue(talk) || step.question === undefined)
713
+ return false;
714
+ const key = this.stepKey(run, step);
715
+ turn.messages.push({ text: (0, template_js_1.render)(step.question, this.scope(turn, run)), kind: "verbatim", afterMs: turn.afterMs, key, runId: run.id, stepId: step.id });
716
+ turn.afterMs = 0;
717
+ turn.spokeBy.add(run.id);
718
+ turn.spoke = true;
719
+ for (const field of pending)
720
+ run.asked[field] = (run.asked[field] ?? 0) + 1;
721
+ this.outcome(turn, run, { kind: "collect", status: "ok", key, code: "asked-fixed", stepId: step.id });
722
+ return true;
614
723
  }
615
724
  /**
616
725
  * Everything Speak needs for this turn's one speaker. Instructions are agent,
@@ -633,12 +742,14 @@ class Runner {
633
742
  const instructions = [...(this.options.instructions ?? []), ...own.instructions].filter((ins) => !ins.if || (0, predicate_js_1.evaluate)(ins.if, ctx, conditions));
634
743
  const all = this.options.tools ?? [];
635
744
  const allowed = own.tools;
745
+ const retry = !("idle" in talk) && this.retrying(talk);
636
746
  return {
637
747
  talk,
638
- 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 } : {}) },
639
749
  context: turn.context,
640
750
  data: turn.session.data,
641
751
  history: this.historyOf(turn),
752
+ said: [...turn.messages],
642
753
  now: turn.now,
643
754
  instructions,
644
755
  tools: allowed ? all.filter((tool) => allowed.includes(tool.id)) : all,
@@ -681,6 +792,11 @@ class Runner {
681
792
  }
682
793
  if (!this.premiseHolds(turn, run, flow))
683
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
+ }
684
800
  const resuming = run.status === "asking";
685
801
  run.status = "running";
686
802
  if (run.stepId === null) {
@@ -723,6 +839,11 @@ class Runner {
723
839
  this.endRun(turn, run, "skipped", "premise-changed");
724
840
  return ok;
725
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
+ }
726
847
  async execute(turn, run, flow, step, resuming) {
727
848
  const data = turn.session.data;
728
849
  const key = this.stepKey(run, step);
@@ -731,7 +852,9 @@ class Runner {
731
852
  let pending = [];
732
853
  if (step.collect?.length) {
733
854
  pending = (0, schema_js_1.pendingFields)(step, data, run.asked);
734
- 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) {
735
858
  this.reportMaxAsks(turn, run, step);
736
859
  this.outcome(turn, run, resuming
737
860
  ? { kind, status: "ok", key, next: nextLabel(step.then) }
@@ -743,6 +866,8 @@ class Runner {
743
866
  if (turn.silenced !== undefined) {
744
867
  if (resuming)
745
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);
746
871
  else
747
872
  this.endRun(turn, run, "skipped", "silenced", kind, turn.silenced);
748
873
  return;
@@ -754,13 +879,19 @@ class Runner {
754
879
  }
755
880
  }
756
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.
757
883
  if (!turn.speakDone)
758
884
  turn.talk = { run, flow, step, pending };
885
+ else
886
+ this.askFixed(turn, { run, flow, step, pending });
759
887
  return;
760
888
  }
761
889
  if (isSay(step)) {
762
890
  if (turn.silenced !== undefined) {
763
- 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);
764
895
  return;
765
896
  }
766
897
  if (step.once) {
@@ -807,6 +938,13 @@ class Runner {
807
938
  result = { failed: error instanceof Error ? error.message : String(error) };
808
939
  }
809
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 && !(0, duration_js_1.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
+ }
810
948
  if ("ok" in result) {
811
949
  if (result.spoke) {
812
950
  turn.spokeBy.add(run.id);
@@ -898,18 +1036,21 @@ class Runner {
898
1036
  }
899
1037
  if ("step" in target) {
900
1038
  const data = turn.session.data;
901
- 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 ?? []) {
902
1041
  delete data[field];
1042
+ delete run.asked[field];
1043
+ }
903
1044
  this.jump(turn, run, flow, target.step);
904
1045
  return;
905
1046
  }
906
1047
  const childId = (0, template_js_1.render)(target.flow, this.scope(turn, run));
907
1048
  const key = this.stepKey(run, step);
908
1049
  this.endRun(turn, run, "flow");
909
- 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);
910
1051
  }
911
- /** Start a child flow that holds the floor and moves in this same phase. */
912
- 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) {
913
1054
  const child = this.flows.get(flowId);
914
1055
  if (!child) {
915
1056
  turn.skipped.push({ flowId, anchor: turn.session.id, triggerKey: key, code: "flow-gone", message: outcomes_js_1.OUTCOME_MESSAGES["flow-gone"] });
@@ -918,9 +1059,11 @@ class Runner {
918
1059
  const run = this.startRun(turn, child, "flow", key, { payload: input, hop, keepData });
919
1060
  if (!run)
920
1061
  return;
921
- turn.floorRunId = run.id;
922
- if (turn.ingesting)
923
- 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
+ }
924
1067
  turn.queue.push(run);
925
1068
  }
926
1069
  jump(turn, run, flow, stepId) {
@@ -935,14 +1078,54 @@ class Runner {
935
1078
  run.visits[stepId] = (run.visits[stepId] ?? 0) + 1;
936
1079
  run.status = "running";
937
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);
938
1102
  }
939
1103
  finishFlow(turn, run, flow) {
940
1104
  const onEnd = flow.onEnd ?? "end";
941
1105
  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";
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
+ }
946
1129
  return;
947
1130
  }
948
1131
  if (onEnd === "reset" && last) {
@@ -950,7 +1133,7 @@ class Runner {
950
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.
951
1134
  const key = this.stepKey(run, last);
952
1135
  this.endRun(turn, run, "reset");
953
- 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);
954
1137
  return;
955
1138
  }
956
1139
  this.endRun(turn, run, "end");
@@ -972,10 +1155,21 @@ class Runner {
972
1155
  run.waiting = { ...waiting, until: at.toISOString(), setAt: turn.nowIso };
973
1156
  turn.schedule.push({ key: waiting.key, at });
974
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
+ }
975
1169
  /** When nobody asks, the most recently suspended run returns to asking. */
976
1170
  resumeSuspended(turn) {
977
1171
  if (this.asker(turn))
978
- return;
1172
+ return undefined;
979
1173
  const suspendedAt = (r) => r.suspendedAt ?? r.startedAt;
980
1174
  const next = turn.session.runs
981
1175
  .filter((r) => r.status === "suspended")
@@ -984,6 +1178,7 @@ class Runner {
984
1178
  next.status = "asking";
985
1179
  delete next.suspendedAt;
986
1180
  }
1181
+ return next;
987
1182
  }
988
1183
  // ── Settle: the one applier after Speak (design §4.7) ─────────────────
989
1184
  async settle(turn, outcome) {
@@ -1038,16 +1233,33 @@ class Runner {
1038
1233
  this.outcome(turn, run, { kind: talkKind(step), status: "ok", key, llmCalls: spoken.llmCalls, stepId: step.id });
1039
1234
  if (!turn.session.runs.includes(run))
1040
1235
  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;
1236
+ const pending = step.collect?.length ? (0, schema_js_1.pendingFields)(step, turn.session.data, run.asked) : [];
1237
+ if (talk.fixedAfter !== undefined) {
1238
+ // The reply only answered the lead; the step's own question goes out after it and counts the ask.
1239
+ if (this.askFixed(turn, { run, flow, step, pending })) {
1046
1240
  run.status = "asking";
1047
1241
  return;
1048
1242
  }
1049
- this.reportMaxAsks(turn, run, step);
1050
1243
  }
1244
+ else {
1245
+ for (const field of pending)
1246
+ run.asked[field] = (run.asked[field] ?? 0) + 1;
1247
+ }
1248
+ if (run.staying) {
1249
+ // 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.
1250
+ const maxAsks = step.maxAsks ?? schema_js_1.DEFAULT_MAX_ASKS;
1251
+ for (const field of pending) {
1252
+ if (run.asked[field] === maxAsks)
1253
+ this.outcome(turn, run, { kind: "collect", status: "skipped", key, code: "max-asks", detail: field });
1254
+ }
1255
+ this.stayAt(run, step);
1256
+ return;
1257
+ }
1258
+ if (pending.length) {
1259
+ run.status = "asking";
1260
+ return;
1261
+ }
1262
+ this.reportMaxAsks(turn, run, step);
1051
1263
  run.status = "running";
1052
1264
  this.follow(turn, run, flow, step, step.then);
1053
1265
  if (turn.session.runs.includes(run) && run.status === "running")
@@ -1084,16 +1296,23 @@ class Runner {
1084
1296
  const reset = deferred.resetAtMs;
1085
1297
  return new Date(reset !== undefined && reset > ladder ? reset : ladder);
1086
1298
  }
1087
- /** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
1299
+ /** The assistant spoke this turn: its silence wakes replace the ones its last words set. */
1088
1300
  armSilence(turn) {
1301
+ const previous = turn.original?.lastAssistantAt;
1302
+ if (turn.session.lastAssistantAt === previous)
1303
+ return;
1304
+ turn.schedule.push(...this.silenceWakes(turn, previous));
1305
+ }
1306
+ /** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
1307
+ silenceWakes(turn, previous) {
1089
1308
  const { session } = turn;
1090
1309
  const { lastAssistantAt, lastUserAt } = session;
1091
- if (!lastAssistantAt || lastAssistantAt === turn.original?.lastAssistantAt)
1092
- return;
1310
+ if (!lastAssistantAt)
1311
+ return [];
1093
1312
  if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt))
1094
- return;
1313
+ return [];
1095
1314
  const ms = Date.parse(lastAssistantAt);
1096
- const previous = turn.original?.lastAssistantAt;
1315
+ const wakes = [];
1097
1316
  for (const flow of this.flows.values()) {
1098
1317
  const trigger = (flow.on ?? []).find((t) => "silence" in t);
1099
1318
  if (!trigger)
@@ -1104,12 +1323,13 @@ class Runner {
1104
1323
  if (!this.repeatAllows(turn, flow, trigger, key))
1105
1324
  continue;
1106
1325
  const at = this.snap(turn, new Date(ms + (0, duration_js_1.parseDuration)(trigger.silence)), trigger.businessHours);
1107
- turn.schedule.push({
1326
+ wakes.push({
1108
1327
  key: `silence:${flow.id}:${session.id}:${ms}`,
1109
1328
  at,
1110
1329
  ...(previous ? { replaces: `silence:${flow.id}:${session.id}:${Date.parse(previous)}` } : {}),
1111
1330
  });
1112
1331
  }
1332
+ return wakes;
1113
1333
  }
1114
1334
  // ── Return (design §4.8) ──────────────────────────────────────────────
1115
1335
  finish(turn) {