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