@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
@@ -8,13 +8,12 @@
8
8
  * begin ─▶ understandRequest ─(Understand)─▶ decide ─▶ advance ─(Speak)─▶ settle ─▶ finish
9
9
  */
10
10
 
11
- import type { AgentOptions, EndReason, TurnInput, TurnResult } from "../types/agent.js";
11
+ import type { AgentOptions, EndReason, PendingWakesInput, ScheduleEntry, TurnBase, TurnInput, TurnResult } from "../types/agent.js";
12
12
  import type { TokenUsage } from "../types/ai.js";
13
13
  import type { ActionResult, Branch, DoStep, Duration, Flow, IfStep, Next, Pred, PredCtx, Repeat, SayStep, Step, StepBase, TalkStep, Trigger, WaitEventStep, WaitStep } from "../types/flow.js";
14
14
  import type { History } from "../types/history.js";
15
15
  import type { Run, Session, StepOutcome, StepOutcomeCode, StepOutcomeKind, TriggerKind } from "../types/session.js";
16
- import { cloneDeep } from "../utils/clone.js";
17
- import { parseDuration } from "../utils/duration.js";
16
+ import { isDuration, parseDuration } from "../utils/duration.js";
18
17
  import { OUTCOME_MESSAGES } from "../utils/outcomes.js";
19
18
  import { coerceField, DEFAULT_MAX_ASKS, extractMode, isKnown, pendingFields } from "../utils/schema.js";
20
19
  import { render, renderDeep } from "../utils/template.js";
@@ -49,7 +48,7 @@ export type What =
49
48
 
50
49
  /** The mutable per-turn state. `session` is a deep copy; the input is never touched. */
51
50
  export interface Turn<C = unknown, D = unknown> {
52
- readonly input: TurnInput<C, D>;
51
+ readonly input: TurnBase<C, D>;
53
52
  readonly what: What;
54
53
  readonly kind: InputKind;
55
54
  /** Message id, wake key, event key or start key: the trigger key of runs started this turn. */
@@ -62,6 +61,8 @@ export interface Turn<C = unknown, D = unknown> {
62
61
  readonly silenced?: string;
63
62
  /** The host gate allows the understand call even though the assistant cannot speak. */
64
63
  readonly understandAllowed: boolean;
64
+ /** The host gate only stops messages: a talk or `say` step is skipped and the run goes on. */
65
+ readonly skipSilenced: boolean;
65
66
  /** Set by a short-circuit: the input is a no-op and nothing is saved. */
66
67
  ignored: boolean;
67
68
  messages: TurnResult<D>["messages"];
@@ -86,6 +87,8 @@ export interface Turn<C = unknown, D = unknown> {
86
87
  /** A short `wait` waiting to ride on the next message. */
87
88
  afterMs: number;
88
89
  talk?: TalkRequest<C, D>;
90
+ /** Understand's judgment that the lead's message asks something; absent when nothing judged it. */
91
+ asks?: boolean;
89
92
  /** Speak already happened (or was skipped): a talk step reached now waits for the next message. */
90
93
  speakDone: boolean;
91
94
  /** Runs still to move this phase; `follow` appends children. */
@@ -137,6 +140,13 @@ function talkKind(step: { collect?: readonly string[] }): StepOutcomeKind {
137
140
  return step.collect?.length ? "collect" : "prompt";
138
141
  }
139
142
 
143
+ /** Everything a flow collects: its own `collect`, then each talk step's, once each. */
144
+ function flowFields<C, D>(flow: Flow<C, D>): string[] {
145
+ const fields = new Set<string>(flow.collect);
146
+ for (const step of flow.steps) if (isTalk(step)) for (const field of step.collect ?? []) fields.add(field);
147
+ return [...fields];
148
+ }
149
+
140
150
  function kindOf<C, D>(step: Step<C, D> | undefined): StepOutcomeKind {
141
151
  if (!step) return "if";
142
152
  if (isTalk(step)) return talkKind(step);
@@ -171,16 +181,31 @@ export class Runner<C = unknown, D = unknown> {
171
181
  // ── Load + Ingest ─────────────────────────────────────────────────────
172
182
 
173
183
  begin(input: TurnInput<C, D>): Turn<C, D> {
174
- const now = (this.options.clock ?? (() => new Date()))();
184
+ const now = this.now();
185
+ const turn = this.open(input, describe(input, now.toISOString()), now);
186
+ if (turn.what.kind === "wake" && !input.session) {
187
+ this.ignore(turn, "no-session", turn.what.key);
188
+ return turn;
189
+ }
190
+ turn.ingesting = true;
191
+ this.ingest(turn);
192
+ turn.ingesting = false;
193
+ return turn;
194
+ }
195
+
196
+ private now(): Date {
197
+ return (this.options.clock ?? (() => new Date()))();
198
+ }
199
+
200
+ private open(input: TurnBase<C, D>, what: What, now: Date): Turn<C, D> {
175
201
  const nowIso = now.toISOString();
176
- const what = describe(input, nowIso);
177
202
  const silenced = typeof input.silenced === "string" ? input.silenced : input.silenced?.reason;
178
- const turn: Turn<C, D> = {
203
+ return {
179
204
  input,
180
205
  what,
181
206
  kind: what.kind,
182
207
  triggerKey: what.kind === "message" ? (what.id ?? what.at) : what.key,
183
- session: input.session ? cloneDeep(input.session) : freshSession<D>(input.sessionId),
208
+ session: input.session ? structuredClone(input.session) : freshSession<D>(input.sessionId),
184
209
  original: input.session,
185
210
  now,
186
211
  nowIso,
@@ -188,6 +213,7 @@ export class Runner<C = unknown, D = unknown> {
188
213
  context: input.context as C,
189
214
  silenced,
190
215
  understandAllowed: typeof input.silenced === "object" && input.silenced.understand === true,
216
+ skipSilenced: typeof input.silenced === "object" && input.silenced.skip === true,
191
217
  ignored: false,
192
218
  messages: [],
193
219
  schedule: [],
@@ -204,14 +230,17 @@ export class Runner<C = unknown, D = unknown> {
204
230
  speakDone: false,
205
231
  queue: [],
206
232
  };
207
- if (what.kind === "wake" && !input.session) {
208
- this.ignore(turn, "no-session", what.key);
209
- return turn;
210
- }
211
- turn.ingesting = true;
212
- this.ingest(turn);
213
- turn.ingesting = false;
214
- return turn;
233
+ }
234
+
235
+ /** Every wake the session waits on: each parked run's own, then each silence flow's since the assistant last spoke. */
236
+ pendingWakes(input: PendingWakesInput<C, D>): ScheduleEntry[] {
237
+ // No input arrives, so nothing reads `what`: the inert wake shape only fills the field.
238
+ const turn = this.open({ ...input, sessionId: input.session.id }, { kind: "wake", key: "" }, this.now());
239
+ // `park` always sets both; the type leaves them optional, so a hand-built blob without them has no wake to give.
240
+ const parked = turn.session.runs.flatMap(({ status, waiting }) =>
241
+ status === "waiting" && waiting?.key && waiting.until ? [{ key: waiting.key, at: new Date(waiting.until) }] : [],
242
+ );
243
+ return [...parked, ...this.silenceWakes(turn)];
215
244
  }
216
245
 
217
246
  private ingest(turn: Turn<C, D>): void {
@@ -285,6 +314,8 @@ export class Runner<C = unknown, D = unknown> {
285
314
  const branch = step.branches?.find((b) => "if" in b && this.holds(b.if, turn, run));
286
315
  const target = branch ? branch.then : step.else;
287
316
  this.outcome(turn, run, { kind: "wait", status: "ok", code: "replied", next: nextLabel(target) });
317
+ // The reply is this run's while it moves, so a stay it reaches answers it.
318
+ if (!turn.floorFromIngest) turn.floorRunId = run.id;
288
319
  this.follow(turn, run, flow, step, target);
289
320
  this.takeFloor(turn, run);
290
321
  }
@@ -323,8 +354,10 @@ export class Runner<C = unknown, D = unknown> {
323
354
  /** `silence:<flowId>:<sessionId>:<lastAssistantAtMs>`, honoured only while the blob still shows that silence. */
324
355
  private silenceWake(turn: Turn<C, D>, key: string): void {
325
356
  const rest = key.slice("silence:".length);
326
- const flowId = rest.slice(0, rest.indexOf(":"));
327
357
  const ms = Number(rest.slice(rest.lastIndexOf(":") + 1));
358
+ // A flow id may hold a ":" itself, so cut at the session id rather than at the first ":".
359
+ const cut = rest.lastIndexOf(`:${turn.session.id}:`);
360
+ const flowId = rest.slice(0, cut === -1 ? rest.indexOf(":") : cut);
328
361
  const { lastAssistantAt } = turn.session;
329
362
  const flow = this.flows.get(flowId);
330
363
  if (!flow) {
@@ -386,11 +419,8 @@ export class Runner<C = unknown, D = unknown> {
386
419
  return null;
387
420
  };
388
421
  if (opts.trigger?.if && !this.holds(opts.trigger.if, turn, run)) return null;
389
- const heldAt = this.claimAt(turn, dedupeKey);
390
- if (heldAt !== undefined) {
391
- if (typeof repeat === "string") return skip("already-claimed");
392
- if (turn.now.getTime() - Date.parse(heldAt) < parseDuration(repeat.cooldown)) return skip("cooldown");
393
- }
422
+ const blocked = this.repeatBlocks(turn, dedupeKey, repeat);
423
+ if (blocked) return skip(blocked);
394
424
  if (hop >= MAX_HOP) return skip("hop-limit");
395
425
  const live = session.runs.find((r) => r.flowId === flow.id && r.anchor === anchor);
396
426
  if (live) {
@@ -407,9 +437,11 @@ export class Runner<C = unknown, D = unknown> {
407
437
  }
408
438
  session.runs.push(run);
409
439
  turn.started.push({ runId: run.id, flowId: flow.id, anchor, dedupeKey });
410
- const after = opts.trigger && "event" in opts.trigger ? opts.trigger.after : undefined;
411
- if (after) {
412
- const at = this.snap(turn, new Date(turn.now.getTime() + parseDuration(after)), opts.trigger && "event" in opts.trigger ? opts.trigger.businessHours : undefined);
440
+ const eventTrigger = opts.trigger && "event" in opts.trigger ? opts.trigger : undefined;
441
+ const after = eventTrigger?.after;
442
+ const at = this.snap(turn, new Date(turn.now.getTime() + (after ? parseDuration(after) : 0)), eventTrigger?.businessHours);
443
+ // With no `after`, only a closed hour parks the start: inside working hours the snap returns now.
444
+ if (after || at.getTime() > turn.now.getTime()) {
413
445
  this.park(turn, run, { kind: "timer", key: `${run.id}:start:${at.getTime()}` }, at);
414
446
  this.outcome(turn, run, { kind: "wait", status: "waiting", code: "awaiting-trigger", until: at.toISOString() });
415
447
  }
@@ -433,10 +465,15 @@ export class Runner<C = unknown, D = unknown> {
433
465
 
434
466
  private repeatAllows(turn: Turn<C, D>, flow: Flow<C, D>, trigger: Trigger<C, D>, key: string): boolean {
435
467
  const repeat = trigger.repeat ?? defaultRepeat(triggerKind(trigger));
436
- const heldAt = this.claimAt(turn, `${flow.id}:${this.anchorOf(turn, flow)}:${repeat === "always" ? key : ""}`);
437
- if (heldAt === undefined) return true;
438
- if (typeof repeat === "string") return false;
439
- return turn.now.getTime() - Date.parse(heldAt) >= parseDuration(repeat.cooldown);
468
+ return !this.repeatBlocks(turn, `${flow.id}:${this.anchorOf(turn, flow)}:${repeat === "always" ? key : ""}`, repeat);
469
+ }
470
+
471
+ /** Why the claim already on `dedupeKey` stops another start, or nothing when there is none or its cooldown has run out. */
472
+ private repeatBlocks(turn: Turn<C, D>, dedupeKey: string, repeat: Repeat): "already-claimed" | "cooldown" | undefined {
473
+ const heldAt = this.claimAt(turn, dedupeKey);
474
+ if (heldAt === undefined) return undefined;
475
+ if (typeof repeat === "string") return "already-claimed";
476
+ return turn.now.getTime() - Date.parse(heldAt) < parseDuration(repeat.cooldown) ? "cooldown" : undefined;
440
477
  }
441
478
 
442
479
  /** A run as it would be if started now, for trigger-level predicates. */
@@ -508,8 +545,7 @@ export class Runner<C = unknown, D = unknown> {
508
545
  const floorRun = this.floorRun(turn);
509
546
  const floorFlow = floorRun && this.flows.get(floorRun.flowId);
510
547
  const eligible = this.eligibleMessageFlows(turn);
511
- // Exactly one eligible message flow and no floor: it starts without scoring (S9).
512
- const messageFlows = !floorRun && eligible.length === 1 ? [] : eligible;
548
+ const messageFlows = !floorRun && this.startsUnscored(turn, eligible) ? [] : eligible;
513
549
  const mentionFlows = this.mentionFlows()
514
550
  .filter(({ flow, trigger }) => trigger.mention.length > 0 && this.repeatAllows(turn, flow, trigger, turn.triggerKey))
515
551
  .map(({ flow }) => flow);
@@ -522,16 +558,25 @@ export class Runner<C = unknown, D = unknown> {
522
558
  }
523
559
  const fields: UnderstandRequest<C, D>["fields"] = {};
524
560
  const data: Record<string, unknown> = turn.session.data;
561
+ const harvestable = (field: string): boolean => {
562
+ const def = this.options.fields[field];
563
+ return def !== undefined && !isKnown(data[field]) && extractMode(def) === "anywhere";
564
+ };
525
565
  for (const flow of [...(floorFlow ? [floorFlow] : []), ...eligible]) {
526
- for (const step of flow.steps) {
527
- if (!isTalk(step)) continue;
528
- for (const field of step.collect ?? []) {
529
- const def = this.options.fields[field];
530
- if (def && !isKnown(data[field]) && extractMode(def) === "anywhere") fields[field] = def;
531
- }
532
- }
566
+ for (const field of flowFields(flow).filter(harvestable)) fields[field] = this.options.fields[field];
567
+ }
568
+ const judging = messageFlows.length > 0 || mentionFlows.length > 0 || branches.length > 0 || Object.keys(fields).length > 0;
569
+ // With nobody on the floor the catch-all may take this message, and an opening message often says the most. The
570
+ // fields its first step asks ride on that step's speak call, so alone they spend no call; a fixed question has no speak call.
571
+ // ponytail: "first step" is steps[0]; a catch-all that opens with a say or an if pays the call for that step's fields too.
572
+ const fallback = floorRun ? undefined : this.messageFlows(turn, (list) => list.length === 0)[0];
573
+ if (fallback) {
574
+ const first = fallback.steps[0];
575
+ const free = new Set<string>(first && isTalk(first) && first.question === undefined ? first.collect : []);
576
+ const own = flowFields(fallback).filter(harvestable);
577
+ if (judging || own.some((field) => !free.has(field))) for (const field of own) fields[field] = this.options.fields[field];
533
578
  }
534
- if (!messageFlows.length && !mentionFlows.length && !branches.length && !Object.keys(fields).length) return null;
579
+ if (!judging && !Object.keys(fields).length) return null;
535
580
  return {
536
581
  text: turn.what.text,
537
582
  history: this.historyOf(turn),
@@ -562,6 +607,14 @@ export class Runner<C = unknown, D = unknown> {
562
607
  return this.messageFlows(turn, (list) => list.length > 0);
563
608
  }
564
609
 
610
+ /**
611
+ * The lone eligible flow starts without a score only when a low score would have nowhere
612
+ * else to send the message: no catch-all passes and the idle speaker is silent (S9).
613
+ */
614
+ private startsUnscored(turn: Turn<C, D>, eligible: Flow<C, D>[]): boolean {
615
+ return eligible.length === 1 && this.options.idle === "silent" && this.messageFlows(turn, (list) => list.length === 0).length === 0;
616
+ }
617
+
565
618
  private messageFlows(turn: Turn<C, D>, accept: (list: string[]) => boolean): Flow<C, D>[] {
566
619
  const out: Flow<C, D>[] = [];
567
620
  for (const flow of this.flows.values()) {
@@ -590,6 +643,7 @@ export class Runner<C = unknown, D = unknown> {
590
643
  if (turn.ignored || turn.what.kind !== "message") return;
591
644
  turn.llmCalls += understanding?.llmCalls ?? 0;
592
645
  turn.usage = addUsage(turn.usage, understanding?.usage);
646
+ if (understanding?.asks !== undefined) turn.asks = understanding.asks;
593
647
  const key = turn.triggerKey;
594
648
  const asker = this.asker(turn);
595
649
 
@@ -605,7 +659,7 @@ export class Runner<C = unknown, D = unknown> {
605
659
  const current = scores[asker.flowId] ?? 0;
606
660
  const top = best(eligible.filter((f) => f.id !== asker.flowId));
607
661
  if (top && top.score >= current + ROUTE_STICKY && top.score >= ROUTE_MIN) route = top.flow;
608
- } else if (eligible.length === 1) {
662
+ } else if (this.startsUnscored(turn, eligible)) {
609
663
  route = eligible[0];
610
664
  } else {
611
665
  const top = best(eligible);
@@ -632,6 +686,9 @@ export class Runner<C = unknown, D = unknown> {
632
686
  }
633
687
  }
634
688
 
689
+ // Unless routing or Ingest moved it, the message is the asker's: written down, so a chain beside it cannot take it.
690
+ if (asker && !route && !turn.floorFromIngest) turn.floorRunId = asker.id;
691
+
635
692
  for (const [field, raw] of Object.entries(understanding?.fields ?? {})) this.writeField(turn, field, raw);
636
693
 
637
694
  if (asker && turn.session.runs.includes(asker) && asker.status === "asking") {
@@ -642,8 +699,15 @@ export class Runner<C = unknown, D = unknown> {
642
699
  }
643
700
 
644
701
  private fireBranch(turn: Turn<C, D>, run: Run, flow: Flow<C, D>, step: TalkOf<C, D>, understanding: Understanding | null): void {
702
+ // Staying, a run does not take an `if` branch back onto a path it has already run: the fact it tests is still true,
703
+ // so it would re-run that path on every message. A `when` branch is judged on each new message and always counts.
704
+ // 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`.
705
+ const ran = (then: Next<D>): boolean =>
706
+ typeof then === "string" ? then === "end" || (run.visits[then] ?? 0) > 0 : "step" in then && (run.visits[then.step] ?? 0) > 0;
645
707
  const hit = (branch: Branch<C, D>, index: number): boolean =>
646
- "if" in branch ? this.holds(branch.if, turn, run) : understanding?.branches[`${run.id}/${step.id}/${index}`] === true;
708
+ "if" in branch
709
+ ? !(run.staying && ran(branch.then)) && this.holds(branch.if, turn, run)
710
+ : understanding?.branches[`${run.id}/${step.id}/${index}`] === true;
647
711
  const index = (step.branches ?? []).findIndex(hit);
648
712
  if (index < 0) return;
649
713
  const branch = (step.branches ?? [])[index];
@@ -673,10 +737,60 @@ export class Runner<C = unknown, D = unknown> {
673
737
 
674
738
  async advance(turn: Turn<C, D>): Promise<TalkRequest<C, D> | IdleRequest<C, D> | null> {
675
739
  if (turn.ignored) return null;
676
- this.resumeSuspended(turn);
740
+ this.resume(turn);
677
741
  turn.queue = [...turn.session.runs];
678
742
  await this.drain(turn);
679
- return this.speaker(turn);
743
+ // An asker that moved on without a word hands the message to the run it suspended, and that one to the next.
744
+ // Each pass takes a run off the suspended stack, so the stack's size bounds the loop.
745
+ for (let left = turn.session.runs.length; left > 0; left--) {
746
+ if (turn.what.kind !== "message" || turn.silenced !== undefined || turn.talk || turn.spokeBy.size > 0) break;
747
+ const resumed = this.resume(turn);
748
+ if (!resumed) break;
749
+ turn.queue.push(resumed);
750
+ await this.drain(turn);
751
+ }
752
+ const speaker = this.speaker(turn);
753
+ if (!speaker || "idle" in speaker || !this.fixedDue(speaker)) return speaker;
754
+ // A question the lead asked gets its answer first: the step speaks, and its fixed question follows in settle.
755
+ // Unjudged counts as asked, and so does the retry of a failed answer: a question sent over the lead's own drops it.
756
+ const answerFirst = turn.what.kind === "message" ? turn.asks !== false : this.retrying(speaker);
757
+ if (answerFirst && speaker.step.question !== undefined) {
758
+ speaker.fixedAfter = render(speaker.step.question, this.scope(turn, speaker.run));
759
+ return speaker;
760
+ }
761
+ this.askFixed(turn, speaker);
762
+ turn.talk = undefined;
763
+ return null;
764
+ }
765
+
766
+ /** The step failed at the provider and runs again on its retry wake: deferTalk left `deferred` as its last line. */
767
+ private retrying({ run, step }: TalkRequest<C, D>): boolean {
768
+ const last = run.outcomes.at(-1);
769
+ return last?.status === "deferred" && last.stepId === step.id;
770
+ }
771
+
772
+ /**
773
+ * The step's fixed question is due: this is its first ask, with every field it collects still unknown and none asked
774
+ * yet, and the run is not staying (a staying run answers the lead). Otherwise the AI phrases the ask.
775
+ */
776
+ private fixedDue({ run, step, pending }: TalkRequest<C, D>): boolean {
777
+ const { question, collect = [] } = step;
778
+ if (question === undefined || run.staying) return false;
779
+ return pending.length === collect.length && pending.every((field) => (run.asked[field] ?? 0) === 0);
780
+ }
781
+
782
+ /** Send the step's fixed question when it is due. It counts as one ask of each field. */
783
+ private askFixed(turn: Turn<C, D>, talk: TalkRequest<C, D>): boolean {
784
+ const { run, step, pending } = talk;
785
+ if (!this.fixedDue(talk) || step.question === undefined) return false;
786
+ const key = this.stepKey(run, step);
787
+ turn.messages.push({ text: render(step.question, this.scope(turn, run)), kind: "verbatim", afterMs: turn.afterMs, key, runId: run.id, stepId: step.id });
788
+ turn.afterMs = 0;
789
+ turn.spokeBy.add(run.id);
790
+ turn.spoke = true;
791
+ for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
792
+ this.outcome(turn, run, { kind: "collect", status: "ok", key, code: "asked-fixed", stepId: step.id });
793
+ return true;
680
794
  }
681
795
 
682
796
  /**
@@ -700,12 +814,14 @@ export class Runner<C = unknown, D = unknown> {
700
814
  const instructions = [...(this.options.instructions ?? []), ...own.instructions].filter((ins) => !ins.if || evaluate(ins.if, ctx, conditions));
701
815
  const all = this.options.tools ?? [];
702
816
  const allowed = own.tools;
817
+ const retry = !("idle" in talk) && this.retrying(talk);
703
818
  return {
704
819
  talk,
705
- input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind },
820
+ input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind, ...(retry ? { retry } : {}) },
706
821
  context: turn.context,
707
822
  data: turn.session.data,
708
823
  history: this.historyOf(turn),
824
+ said: [...turn.messages],
709
825
  now: turn.now,
710
826
  instructions,
711
827
  tools: allowed ? all.filter((tool) => allowed.includes(tool.id)) : all,
@@ -746,6 +862,11 @@ export class Runner<C = unknown, D = unknown> {
746
862
  return;
747
863
  }
748
864
  if (!this.premiseHolds(turn, run, flow)) return;
865
+ // The flow was edited off 'stay' after this run finished its steps: its new `onEnd` decides now.
866
+ if (run.staying && flow.onEnd !== "stay") {
867
+ this.finishFlow(turn, run, flow);
868
+ return;
869
+ }
749
870
  const resuming = run.status === "asking";
750
871
  run.status = "running";
751
872
  if (run.stepId === null) {
@@ -788,6 +909,12 @@ export class Runner<C = unknown, D = unknown> {
788
909
  return ok;
789
910
  }
790
911
 
912
+ /** `silenced: { skip: true }`: the message this step would send is dropped, and the run takes `then` as if it had gone out. */
913
+ private skipSilenced(turn: Turn<C, D>, run: Run, flow: Flow<C, D>, step: Step<C, D>, kind: StepOutcomeKind): void {
914
+ this.outcome(turn, run, { kind, status: "skipped", key: this.stepKey(run, step), code: "silenced", detail: turn.silenced, next: nextLabel(step.then) });
915
+ this.follow(turn, run, flow, step, step.then);
916
+ }
917
+
791
918
  private async execute(turn: Turn<C, D>, run: Run, flow: Flow<C, D>, step: Step<C, D>, resuming: boolean): Promise<void> {
792
919
  const data: Record<string, unknown> = turn.session.data;
793
920
  const key = this.stepKey(run, step);
@@ -797,7 +924,9 @@ export class Runner<C = unknown, D = unknown> {
797
924
  let pending: string[] = [];
798
925
  if (step.collect?.length) {
799
926
  pending = pendingFields(step, data, run.asked);
800
- if (!pending.length) {
927
+ // A `stay` run answers even with nothing left to collect: answering is what it stays for. It only runs on a message
928
+ // (asking runs sit out wakes and events), and on a silenced one the check below keeps it asking.
929
+ if (!pending.length && !run.staying) {
801
930
  this.reportMaxAsks(turn, run, step);
802
931
  this.outcome(turn, run, resuming
803
932
  ? { kind, status: "ok", key, next: nextLabel(step.then) }
@@ -808,6 +937,7 @@ export class Runner<C = unknown, D = unknown> {
808
937
  }
809
938
  if (turn.silenced !== undefined) {
810
939
  if (resuming) run.status = "asking"; // the gate is closed; it speaks when the gate opens
940
+ else if (turn.skipSilenced) this.skipSilenced(turn, run, flow, step, kind);
811
941
  else this.endRun(turn, run, "skipped", "silenced", kind, turn.silenced);
812
942
  return;
813
943
  }
@@ -818,13 +948,16 @@ export class Runner<C = unknown, D = unknown> {
818
948
  }
819
949
  }
820
950
  run.status = "asking";
951
+ // 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.
821
952
  if (!turn.speakDone) turn.talk = { run, flow, step, pending };
953
+ else this.askFixed(turn, { run, flow, step, pending });
822
954
  return;
823
955
  }
824
956
 
825
957
  if (isSay(step)) {
826
958
  if (turn.silenced !== undefined) {
827
- this.endRun(turn, run, "skipped", "silenced", "say", turn.silenced);
959
+ if (turn.skipSilenced) this.skipSilenced(turn, run, flow, step, "say");
960
+ else this.endRun(turn, run, "skipped", "silenced", "say", turn.silenced);
828
961
  return;
829
962
  }
830
963
  if (step.once) {
@@ -870,6 +1003,13 @@ export class Runner<C = unknown, D = unknown> {
870
1003
  result = { failed: error instanceof Error ? error.message : String(error) };
871
1004
  }
872
1005
  }
1006
+ // The handler already ran, so a defer that cannot be parsed must not throw
1007
+ // the turn: every replay would repeat the side effect and fail again.
1008
+ if ("defer" in result && !isDuration(String(result.defer))) {
1009
+ result = {
1010
+ failed: `action "${step.do}" asked to defer by "${String(result.defer)}", which is not a duration. Use a value like "2m" or "1h".`,
1011
+ };
1012
+ }
873
1013
  if ("ok" in result) {
874
1014
  if (result.spoke) {
875
1015
  turn.spokeBy.add(run.id);
@@ -959,18 +1099,22 @@ export class Runner<C = unknown, D = unknown> {
959
1099
  }
960
1100
  if ("step" in target) {
961
1101
  const data: Record<string, unknown> = turn.session.data;
962
- for (const field of target.clear ?? []) delete data[field];
1102
+ // Forgetting a field means asking for it from scratch: its ask count goes too, so a fixed question goes out again.
1103
+ for (const field of target.clear ?? []) {
1104
+ delete data[field];
1105
+ delete run.asked[field];
1106
+ }
963
1107
  this.jump(turn, run, flow, target.step);
964
1108
  return;
965
1109
  }
966
1110
  const childId = render(target.flow, this.scope(turn, run));
967
1111
  const key = this.stepKey(run, step);
968
1112
  this.endRun(turn, run, "flow");
969
- this.chain(turn, childId, key, target.input ?? run.input, run.hop + 1);
1113
+ this.chain(turn, run, childId, key, target.input ?? run.input, run.hop + 1);
970
1114
  }
971
1115
 
972
- /** Start a child flow that holds the floor and moves in this same phase. */
973
- private chain(turn: Turn<C, D>, flowId: string, key: string, input: unknown, hop: number, keepData = false): void {
1116
+ /** Start a child flow that moves in this same phase. It inherits the floor when its parent held it, or when nobody did. */
1117
+ private chain(turn: Turn<C, D>, parent: Run, flowId: string, key: string, input: unknown, hop: number, keepData = false): void {
974
1118
  const child = this.flows.get(flowId);
975
1119
  if (!child) {
976
1120
  turn.skipped.push({ flowId, anchor: turn.session.id, triggerKey: key, code: "flow-gone", message: OUTCOME_MESSAGES["flow-gone"] });
@@ -978,8 +1122,10 @@ export class Runner<C = unknown, D = unknown> {
978
1122
  }
979
1123
  const run = this.startRun(turn, child, "flow", key, { payload: input, hop, keepData });
980
1124
  if (!run) return;
981
- turn.floorRunId = run.id;
982
- if (turn.ingesting) turn.floorFromIngest = true;
1125
+ if (turn.floorRunId === undefined || turn.floorRunId === parent.id) {
1126
+ turn.floorRunId = run.id;
1127
+ if (turn.ingesting) turn.floorFromIngest = true;
1128
+ }
983
1129
  turn.queue.push(run);
984
1130
  }
985
1131
 
@@ -994,15 +1140,55 @@ export class Runner<C = unknown, D = unknown> {
994
1140
  run.visits[stepId] = (run.visits[stepId] ?? 0) + 1;
995
1141
  run.status = "running";
996
1142
  delete run.waiting;
1143
+ delete run.staying;
1144
+ }
1145
+
1146
+ /** `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. */
1147
+ private stayAt(run: Run, step: Step<C, D>): void {
1148
+ this.enter(run, step.id);
1149
+ run.staying = true;
1150
+ run.status = "asking";
1151
+ }
1152
+
1153
+ /**
1154
+ * 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.
1155
+ * 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
1156
+ * stays on the flow's last talk step. Upgrade: record the talk step on the run when it talks.
1157
+ */
1158
+ private stayStep(run: Run, flow: Flow<C, D>): TalkOf<C, D> | undefined {
1159
+ for (let i = run.outcomes.length - 1; i >= 0; i--) {
1160
+ const { kind, stepId } = run.outcomes[i];
1161
+ const step = kind === "prompt" || kind === "collect" ? this.stepOf(flow, stepId ?? null) : undefined;
1162
+ if (step && isTalk(step)) return step;
1163
+ }
1164
+ return [...flow.steps].reverse().find(isTalk);
997
1165
  }
998
1166
 
999
1167
  private finishFlow(turn: Turn<C, D>, run: Run, flow: Flow<C, D>): void {
1000
1168
  const onEnd = flow.onEnd ?? "end";
1001
1169
  const last = flow.steps[flow.steps.length - 1];
1002
- if (onEnd === "stay" && last) {
1003
- // "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.
1004
- this.enter(run, last.id);
1005
- run.status = "asking";
1170
+ const stay = onEnd === "stay" ? this.stayStep(run, flow) : undefined;
1171
+ if (stay) {
1172
+ // The steps after it ran once, on the way here; staying, it only answers.
1173
+ this.stayAt(run, stay);
1174
+ // One asker at a time. A run the lead's message went to holds the conversation and suspends the other asker;
1175
+ // any other run waits behind that asker and resumes when it is done.
1176
+ const others = turn.session.runs.filter((r) => r !== run && r.status === "asking");
1177
+ if (others.length && !(turn.what.kind === "message" && turn.floorRunId === run.id)) {
1178
+ run.status = "suspended";
1179
+ run.suspendedAt = turn.nowIso;
1180
+ return;
1181
+ }
1182
+ for (const other of others) {
1183
+ other.status = "suspended";
1184
+ other.suspendedAt = turn.nowIso;
1185
+ if (turn.talk?.run === other) turn.talk = undefined;
1186
+ }
1187
+ // A message nothing has answered yet gets its answer now. Reached in Ingest, the run stays asking: Decide judges its
1188
+ // branches as the asker's, and it answers when it moves.
1189
+ if (!turn.ingesting && turn.what.kind === "message" && turn.silenced === undefined && !turn.speakDone && turn.spokeBy.size === 0) {
1190
+ run.status = "running";
1191
+ }
1006
1192
  return;
1007
1193
  }
1008
1194
  if (onEnd === "reset" && last) {
@@ -1010,7 +1196,7 @@ export class Runner<C = unknown, D = unknown> {
1010
1196
  // 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.
1011
1197
  const key = this.stepKey(run, last);
1012
1198
  this.endRun(turn, run, "reset");
1013
- this.chain(turn, flow.id, key, run.input, run.hop + 1, true);
1199
+ this.chain(turn, run, flow.id, key, run.input, run.hop + 1, true);
1014
1200
  return;
1015
1201
  }
1016
1202
  this.endRun(turn, run, "end");
@@ -1033,9 +1219,20 @@ export class Runner<C = unknown, D = unknown> {
1033
1219
  turn.schedule.push({ key: waiting.key, at });
1034
1220
  }
1035
1221
 
1222
+ /** `resumeSuspended`, and on a message the resumed run's `if` branches are judged before it speaks, as the asker's are in Decide. */
1223
+ private resume(turn: Turn<C, D>): Run | undefined {
1224
+ const run = this.resumeSuspended(turn);
1225
+ if (run && turn.what.kind === "message") {
1226
+ const flow = this.flows.get(run.flowId);
1227
+ const step = flow && this.stepOf(flow, run.stepId);
1228
+ if (flow && step && isTalk(step)) this.fireBranch(turn, run, flow, step, null);
1229
+ }
1230
+ return run;
1231
+ }
1232
+
1036
1233
  /** When nobody asks, the most recently suspended run returns to asking. */
1037
- private resumeSuspended(turn: Turn<C, D>): void {
1038
- if (this.asker(turn)) return;
1234
+ private resumeSuspended(turn: Turn<C, D>): Run | undefined {
1235
+ if (this.asker(turn)) return undefined;
1039
1236
  const suspendedAt = (r: Run): string => r.suspendedAt ?? r.startedAt;
1040
1237
  const next = turn.session.runs
1041
1238
  .filter((r) => r.status === "suspended")
@@ -1044,6 +1241,7 @@ export class Runner<C = unknown, D = unknown> {
1044
1241
  next.status = "asking";
1045
1242
  delete next.suspendedAt;
1046
1243
  }
1244
+ return next;
1047
1245
  }
1048
1246
 
1049
1247
  // ── Settle: the one applier after Speak (design §4.7) ─────────────────
@@ -1093,15 +1291,30 @@ export class Runner<C = unknown, D = unknown> {
1093
1291
  Object.assign(turn.session.data, spoken.data);
1094
1292
  this.outcome(turn, run, { kind: talkKind(step), status: "ok", key, llmCalls: spoken.llmCalls, stepId: step.id });
1095
1293
  if (!turn.session.runs.includes(run)) return;
1096
- if (step.collect?.length) {
1097
- const pending = pendingFields(step, turn.session.data, run.asked);
1098
- if (pending.length) {
1099
- for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
1294
+ const pending = step.collect?.length ? pendingFields(step, turn.session.data, run.asked) : [];
1295
+ if (talk.fixedAfter !== undefined) {
1296
+ // The reply only answered the lead; the step's own question goes out after it and counts the ask.
1297
+ if (this.askFixed(turn, { run, flow, step, pending })) {
1100
1298
  run.status = "asking";
1101
1299
  return;
1102
1300
  }
1103
- this.reportMaxAsks(turn, run, step);
1301
+ } else {
1302
+ for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
1303
+ }
1304
+ if (run.staying) {
1305
+ // 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.
1306
+ const maxAsks = step.maxAsks ?? DEFAULT_MAX_ASKS;
1307
+ for (const field of pending) {
1308
+ if (run.asked[field] === maxAsks) this.outcome(turn, run, { kind: "collect", status: "skipped", key, code: "max-asks", detail: field });
1309
+ }
1310
+ this.stayAt(run, step);
1311
+ return;
1312
+ }
1313
+ if (pending.length) {
1314
+ run.status = "asking";
1315
+ return;
1104
1316
  }
1317
+ this.reportMaxAsks(turn, run, step);
1105
1318
  run.status = "running";
1106
1319
  this.follow(turn, run, flow, step, step.then);
1107
1320
  if (turn.session.runs.includes(run) && run.status === "running") turn.queue.push(run);
@@ -1139,14 +1352,21 @@ export class Runner<C = unknown, D = unknown> {
1139
1352
  return new Date(reset !== undefined && reset > ladder ? reset : ladder);
1140
1353
  }
1141
1354
 
1142
- /** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
1355
+ /** The assistant spoke this turn: its silence wakes replace the ones its last words set. */
1143
1356
  private armSilence(turn: Turn<C, D>): void {
1357
+ const previous = turn.original?.lastAssistantAt;
1358
+ if (turn.session.lastAssistantAt === previous) return;
1359
+ turn.schedule.push(...this.silenceWakes(turn, previous));
1360
+ }
1361
+
1362
+ /** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
1363
+ private silenceWakes(turn: Turn<C, D>, previous?: string): ScheduleEntry[] {
1144
1364
  const { session } = turn;
1145
1365
  const { lastAssistantAt, lastUserAt } = session;
1146
- if (!lastAssistantAt || lastAssistantAt === turn.original?.lastAssistantAt) return;
1147
- if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt)) return;
1366
+ if (!lastAssistantAt) return [];
1367
+ if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt)) return [];
1148
1368
  const ms = Date.parse(lastAssistantAt);
1149
- const previous = turn.original?.lastAssistantAt;
1369
+ const wakes: ScheduleEntry[] = [];
1150
1370
  for (const flow of this.flows.values()) {
1151
1371
  const trigger = (flow.on ?? []).find((t): t is SilenceTrigger<C, D> => "silence" in t);
1152
1372
  if (!trigger) continue;
@@ -1154,12 +1374,13 @@ export class Runner<C = unknown, D = unknown> {
1154
1374
  if (trigger.if && !this.holds(trigger.if, turn, this.draftRun(turn, flow, "silence", key))) continue;
1155
1375
  if (!this.repeatAllows(turn, flow, trigger, key)) continue;
1156
1376
  const at = this.snap(turn, new Date(ms + parseDuration(trigger.silence)), trigger.businessHours);
1157
- turn.schedule.push({
1377
+ wakes.push({
1158
1378
  key: `silence:${flow.id}:${session.id}:${ms}`,
1159
1379
  at,
1160
1380
  ...(previous ? { replaces: `silence:${flow.id}:${session.id}:${Date.parse(previous)}` } : {}),
1161
1381
  });
1162
1382
  }
1383
+ return wakes;
1163
1384
  }
1164
1385
 
1165
1386
  // ── Return (design §4.8) ──────────────────────────────────────────────