@falai/agent 4.0.0-alpha.10 → 4.0.0-alpha.12

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 (52) hide show
  1. package/dist/cjs/core/FlowSpec.js +1 -1
  2. package/dist/cjs/core/FlowSpec.js.map +1 -1
  3. package/dist/cjs/core/Migrate.d.ts.map +1 -1
  4. package/dist/cjs/core/Migrate.js +3 -1
  5. package/dist/cjs/core/Migrate.js.map +1 -1
  6. package/dist/cjs/core/Runner.d.ts +11 -1
  7. package/dist/cjs/core/Runner.d.ts.map +1 -1
  8. package/dist/cjs/core/Runner.js +120 -26
  9. package/dist/cjs/core/Runner.js.map +1 -1
  10. package/dist/cjs/core/Speak.d.ts.map +1 -1
  11. package/dist/cjs/core/Speak.js +3 -1
  12. package/dist/cjs/core/Speak.js.map +1 -1
  13. package/dist/cjs/types/flow.d.ts +1 -1
  14. package/dist/cjs/types/flow.d.ts.map +1 -1
  15. package/dist/cjs/types/session.d.ts +2 -0
  16. package/dist/cjs/types/session.d.ts.map +1 -1
  17. package/dist/core/FlowSpec.js +1 -1
  18. package/dist/core/FlowSpec.js.map +1 -1
  19. package/dist/core/Migrate.d.ts.map +1 -1
  20. package/dist/core/Migrate.js +3 -1
  21. package/dist/core/Migrate.js.map +1 -1
  22. package/dist/core/Runner.d.ts +11 -1
  23. package/dist/core/Runner.d.ts.map +1 -1
  24. package/dist/core/Runner.js +120 -26
  25. package/dist/core/Runner.js.map +1 -1
  26. package/dist/core/Speak.d.ts.map +1 -1
  27. package/dist/core/Speak.js +3 -1
  28. package/dist/core/Speak.js.map +1 -1
  29. package/dist/types/flow.d.ts +1 -1
  30. package/dist/types/flow.d.ts.map +1 -1
  31. package/dist/types/session.d.ts +2 -0
  32. package/dist/types/session.d.ts.map +1 -1
  33. package/docs/concepts/pipeline.md +3 -3
  34. package/docs/concepts/runs-and-waits.md +1 -1
  35. package/docs/guides/branching.md +3 -1
  36. package/docs/guides/flow-control.md +3 -1
  37. package/docs/guides/triggers.md +2 -2
  38. package/docs/migration/v3-to-v4.md +1 -1
  39. package/docs/reference/actions-events-conditions.md +1 -1
  40. package/docs/reference/branches.md +1 -1
  41. package/docs/reference/flow.md +1 -1
  42. package/docs/reference/session.md +2 -0
  43. package/docs/reference/step.md +2 -2
  44. package/docs/reference/trigger.md +2 -2
  45. package/examples/05-branches.ts +1 -1
  46. package/package.json +1 -1
  47. package/src/core/FlowSpec.ts +1 -1
  48. package/src/core/Migrate.ts +2 -1
  49. package/src/core/Runner.ts +116 -25
  50. package/src/core/Speak.ts +3 -1
  51. package/src/types/flow.ts +1 -1
  52. package/src/types/session.ts +2 -0
@@ -220,7 +220,7 @@ An event turn never spends an understand call. It costs one speak call (plus too
220
220
 
221
221
  1. **Direction.** `'inbound'` sets `lastUserAt` to now and resolves reply waits: every run parked on a timer `wait` that has an `else` resumes with `code: 'replied'` and follows a matching `if` branch's `then`, else `else`. `'outbound'` sets `lastAssistantAt` to now, which re-arms `silence` triggers at the end of the turn.
222
222
  2. **Waiting runs.** Every run parked on `wait: { event: name }` for this name resumes with `code: 'event-arrived'` and follows `then`. The first one to resume takes the floor for this turn.
223
- 3. **Triggers.** Every flow with `on: [{ event: name }]` goes through the start order: trigger `if`, `repeat` (default `'always'` for events), the hop cap, one live run per flow and anchor. With `after`, the run parks first (`code: 'awaiting-trigger'`, wake key `${runId}:start:${atMs}`) and enters its first step when the wake fires; `businessHours: true` snaps that time forward through the agent's `businessHours` function.
223
+ 3. **Triggers.** Every flow with `on: [{ event: name }]` goes through the start order: trigger `if`, `repeat` (default `'always'` for events), the hop cap, one live run per flow and anchor. With `after`, the run parks first (`code: 'awaiting-trigger'`, wake key `${runId}:start:${atMs}`) and enters its first step when the wake fires; `businessHours: true` snaps that time forward through the agent's `businessHours` function. With `businessHours: true` and no `after`, an event that arrives outside working hours parks the run the same way until the next working moment.
224
224
 
225
225
  `wait: { event, upTo }` in a step parks the run for at most `upTo` (default `'30d'`, from `src/core/Runner.ts`). If the event never comes, the line carries `code: 'no-event'` and the run follows `else`, or ends when there is none.
226
226
 
@@ -34,7 +34,7 @@ type Branch<C = unknown, D = unknown> = { then: Next<D> } & (
34
34
  3. The run leaves the step with the outcome `code: 'branch'` (kind `prompt` or `collect`, status `ok`, `next` naming the target) and follows `then` in the same turn. Fields the understand call extracted from the same message are written first, so `then` may land on a step that is already satisfied.
35
35
  4. When no branch holds, the step continues as usual: pending fields are harvested and the step speaks again.
36
36
 
37
- A `when` branch costs the understand call; when it is the only thing to judge, that is one model call the turn would not otherwise spend. An `if` branch is judged on every message turn even when no understand call happens.
37
+ A `when` branch costs the understand call; when it is the only thing to judge, that is one model call the turn would not otherwise spend. An `if` branch is judged on every message turn even when no understand call happens. A suspended run that returns to asking during the turn, because the asker ended or moved on without a word, has its `if` branches judged before its step speaks; its `when` branches were not in the understand call, so they wait for the next message.
38
38
 
39
39
  **On a wait step.** Only `if` branches are judged, and only when the customer writes before the time passes and the step has `else`. The first `if` branch that holds replaces `else` as the target (outcome `code: 'replied'`). A `when` branch on a wait step is never asked, because a reply to a wait step does not reach the understand call. When the wake fires, branches are not consulted: the run follows `then`, or `else` when the customer wrote after the wait was set.
40
40
 
@@ -63,7 +63,7 @@ It enters `steps[0]` in the same turn unless the trigger has `after`. Keys and s
63
63
 
64
64
  **`onEnd`.**
65
65
  - `'end'`: the run ends with reason `'end'`.
66
- - `'stay'`: the run re-enters the last step (a new visit, so new keys) and stays asking. It speaks that step again on the next message. Meant for a last talk step that answers follow-up questions.
66
+ - `'stay'`: the run goes back to the last talk step it took and stays there, asking. On a branched flow that is the step on its own path; when its log names none, the flow's last talk step. From then on that step answers every message, even when it has nothing left to collect, and each answer is a new visit, so a new key. The steps after it ran once, on the way to the end, and do not run again. If the run finishes on a message nothing has answered yet (no `say`, no `spoke: true`), the step answers it in that same turn. If another run is asking, the staying run waits `suspended` behind it and takes over when that run is done, answering the message that run moved on from without a word. When the customer's message belongs to this run (it was routed to this flow, or it resolved this run's `wait`), the run takes the conversation instead: the other asker is suspended, and this run answers unless its own `say` already did. Its `when` branches are judged on every message and still move the run. Its `if` branches are judged too, except one that leads to `'end'` or to a step the run has already been through: that path already ran and the fact is still true, so it would run again on every message. A field still pending there (a `when` branch took the run to the end before the customer gave it) is asked for on each answer, each with its own key, and `max-asks` is reported once, on the answer that reaches the limit. If the flow is edited off `'stay'` while a run stays, the new `onEnd` applies on that run's next message. A flow with no talk step ends, as with `'end'`.
67
67
  - `'reset'`: the run ends with reason `'reset'` and a fresh run of the same flow starts at `steps[0]`, data kept, one hop deeper. A flow that resets forever without asking anything stops at hop 5, with `code: 'hop-limit'`.
68
68
 
69
69
  **Instructions and tools while speaking.** The speak call sees the agent's instructions, then this flow's, then the step's, each already filtered by its `if`. Tools are the step's `tools`; without one, the flow's `tools`; without that, every agent tool.
@@ -38,6 +38,7 @@ interface Run {
38
38
  hop: number;
39
39
  startedAt: string;
40
40
  suspendedAt?: string;
41
+ staying?: true;
41
42
  waiting?: { kind: "timer" | "event"; key?: string; until?: string; setAt: string; event?: string };
42
43
  asked: Record<string, number>;
43
44
  visits: Record<string, number>;
@@ -86,6 +87,7 @@ From `src/types/session.ts`. Every date is ISO 8601 text, never a `Date`: the bl
86
87
  | `hop` | `number` | Chaining depth. `0` for a run a trigger started; `+1` per `{ flow }` move and per `onEnd: 'reset'`. A start at hop 5 is skipped: `code: 'hop-limit'` on `TurnResult.skipped`. |
87
88
  | `startedAt` | `string` | The clock's now when the run started. |
88
89
  | `suspendedAt` | `string?` | Set while `suspended`. The most recently suspended run is the one that resumes. |
90
+ | `staying` | `true?` | Set once an `onEnd: 'stay'` run has finished its steps and sits on its last talk step, answering every message. Any other move clears it. |
89
91
  | `waiting` | object? | Set while `waiting`. See below. |
90
92
  | `asked` | `Record<string, number>` | Per field, how many times a talk step spoke with that field still pending. A field at the step's `maxAsks` (default 3, `src/utils/schema.ts`) leaves the pending set: `code: 'max-asks'`, with the field's slug in `detail`. |
91
93
  | `visits` | `Record<string, number>` | Per step, how many times this run entered it. Part of every message and action key, so a revisit mints new keys. |
@@ -153,9 +153,9 @@ The code forks. No model call.
153
153
  | Form | Meaning |
154
154
  |---|---|
155
155
  | `'passo'` | Jump to that step id. |
156
- | `'end'` | Finish the run here, exactly as running past the last step does — `onEnd` still decides: `'end'` ends it, `'stay'` re-enters the last step and keeps asking, `'reset'` starts a fresh run. `'end'` is reserved: no step may use it as an id. |
156
+ | `'end'` | Finish the run here, exactly as running past the last step does — `onEnd` still decides: `'end'` ends it, `'stay'` goes back to the last talk step and answers every message from there, `'reset'` starts a fresh run. `'end'` is reserved: no step may use it as an id. |
157
157
  | `{ step: 'passo', clear: ['campo'] }` | Delete the listed fields from `session.data`, then jump. The way to ask something again. |
158
- | `{ flow: 'outro', input? }` | End this run (reason `'flow'`) and start `outro` in the same turn, one hop deeper. The child gets `input`, or this run's `input` when absent, and holds the floor. `flow` is a template. |
158
+ | `{ flow: 'outro', input? }` | End this run (reason `'flow'`) and start `outro` in the same turn, one hop deeper. The child gets `input`, or this run's `input` when absent. It holds the floor when this run did, or when no run did: a `mention` flow that chains does not take the message from the run it was routed to. `flow` is a template. |
159
159
 
160
160
  Entering a step counts a visit; the visit is part of every key minted there, so a step visited twice sends twice. A `{ step }` jump to an id that no longer exists ends the run with `code: 'step-gone'`; a `{ flow }` to an unknown flow is skipped with `code: 'flow-gone'`.
161
161
 
@@ -85,7 +85,7 @@ A trigger whose phrases are *all* exclusions can never fire, so `validateFlow` r
85
85
  | `event` | `string` | required | The event's name in the agent's `events`. |
86
86
  | `after` | `Duration` | none | Park the run this long before its first step. |
87
87
  | `if` | `Pred<C, D>` | none | Judged when the event arrives, with the payload as `input`. |
88
- | `businessHours` | `boolean` | `false` | Snap the `after` wake forward. |
88
+ | `businessHours` | `boolean` | `false` | Start only in working hours: snap the start (now, or now + `after`) forward with the agent's `businessHours`. |
89
89
  | `repeat` | `Repeat` | `'always'` | |
90
90
 
91
91
  The event's `payload` is the run's `input`.
@@ -116,7 +116,7 @@ Pass a real message `id` on every message turn. Only `id` is checked against the
116
116
 
117
117
  **Dedupe key.** `${flowId}:${anchor}:${nonce}`. The nonce is the trigger key when `repeat` is `'always'` and empty otherwise. It is written to `session.claims` when the run starts, given to actions as `ctx.dedupeKey`, and returned in `started[]`. The last 50 `'always'` claims per flow and anchor are kept; `'once'` and cooldown claims are never pruned. The host may pass claims from the customer's other sessions in `turn({ claims })`; they count the same.
118
118
 
119
- **Wake for `after`.** `${runId}:start:${atMs}`, where `atMs` is the fire time in milliseconds after `businessHours` snapping. The run is returned in `started[]` at once with the outcome `code: 'awaiting-trigger'`. At the wake it enters its first step. A second event for the same flow and anchor while it is parked replaces it: the parked run ends with reason `'replaced'`.
119
+ **Wake for `after`.** `${runId}:start:${atMs}`, where `atMs` is the fire time in milliseconds after `businessHours` snapping. With `businessHours: true` and no `after`, the same wake parks a run whose event arrives outside working hours; inside them it starts at once. The run is returned in `started[]` at once with the outcome `code: 'awaiting-trigger'`. At the wake it enters its first step. A second event for the same flow and anchor while it is parked replaces it: the parked run ends with reason `'replaced'`.
120
120
 
121
121
  **Wake for silence.** `silence:${flowId}:${sessionId}:${lastAssistantAtMs}`. Armed at the end of every turn in which the assistant spoke and the customer has not written since, for every silence flow passing `if` and `repeat`; the entry's `replaces` names the previous silence wake. At fire time it is honoured only while `session.lastAssistantAt` still equals that timestamp and the customer has not written since (`code: 'silence-broken'` otherwise).
122
122
 
@@ -54,7 +54,7 @@ const agent = f.agent({
54
54
  then: "dados",
55
55
  },
56
56
  ],
57
- // After the last step the run stays on it, so follow-up questions land here.
57
+ // After the last step the run goes back to the last talk step it took, so follow-up questions land there.
58
58
  onEnd: "stay",
59
59
  }),
60
60
  f.flow({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@falai/agent",
3
3
  "packageManager": "bun@1.4.2",
4
- "version": "4.0.0-alpha.10",
4
+ "version": "4.0.0-alpha.12",
5
5
  "description": "Conversational state engine for TypeScript where the AI understands, but the code is in control",
6
6
  "type": "module",
7
7
  "main": "./dist/cjs/index.js",
@@ -702,7 +702,7 @@ export function flowSpecSchema(registries: Registries): StructuredSchema {
702
702
  while: orNull({ ...condition, description: "The run ends when this stops holding" }),
703
703
  clearOnStart: slugList && orNull({ ...slugList, description: "Fields to forget when a run starts" }),
704
704
  steps: list(step, "In order; a run moves to the next step unless `then` says otherwise"),
705
- onEnd: orNull(enumOf(["end", "stay", "reset"], "After the last step: end the run, stay on it, or reset to the first")),
705
+ onEnd: orNull(enumOf(["end", "stay", "reset"], "After the last step: end the run, stay on the last talk step it took answering every message, or reset to the first")),
706
706
  instructions: orNull(list(instruction, "Rules that apply only inside this flow")),
707
707
  });
708
708
  }
@@ -112,7 +112,7 @@ function checkRun(raw: unknown, index: number, bad: Bad): Run {
112
112
 
113
113
  const status = text("status");
114
114
  if (!RUN_STATUS.has(status)) throw bad(`${at}.status is "${status}"`);
115
- const { stepId, hop, outcomes, input, waiting, suspendedAt } = raw;
115
+ const { stepId, hop, outcomes, input, waiting, suspendedAt, staying } = raw;
116
116
  if (stepId !== null && typeof stepId !== "string") throw bad(`${at}.stepId is ${describe(stepId)}, expected text or null`);
117
117
  if (!isWhole(hop)) throw bad(`${at}.hop is ${describe(hop)}, expected a whole number`);
118
118
  if (!Array.isArray(outcomes)) throw bad(`${at}.outcomes is ${describe(outcomes)}, expected a list`);
@@ -139,6 +139,7 @@ function checkRun(raw: unknown, index: number, bad: Bad): Run {
139
139
  if (input !== undefined) run.input = input;
140
140
  if (waiting !== undefined) run.waiting = waiting as Run["waiting"];
141
141
  if (typeof suspendedAt === "string") run.suspendedAt = suspendedAt;
142
+ if (staying === true) run.staying = true;
142
143
  return run;
143
144
  }
144
145
 
@@ -303,6 +303,8 @@ export class Runner<C = unknown, D = unknown> {
303
303
  const branch = step.branches?.find((b) => "if" in b && this.holds(b.if, turn, run));
304
304
  const target = branch ? branch.then : step.else;
305
305
  this.outcome(turn, run, { kind: "wait", status: "ok", code: "replied", next: nextLabel(target) });
306
+ // The reply is this run's while it moves, so a stay it reaches answers it.
307
+ if (!turn.floorFromIngest) turn.floorRunId = run.id;
306
308
  this.follow(turn, run, flow, step, target);
307
309
  this.takeFloor(turn, run);
308
310
  }
@@ -425,9 +427,11 @@ export class Runner<C = unknown, D = unknown> {
425
427
  }
426
428
  session.runs.push(run);
427
429
  turn.started.push({ runId: run.id, flowId: flow.id, anchor, dedupeKey });
428
- const after = opts.trigger && "event" in opts.trigger ? opts.trigger.after : undefined;
429
- if (after) {
430
- const at = this.snap(turn, new Date(turn.now.getTime() + parseDuration(after)), opts.trigger && "event" in opts.trigger ? opts.trigger.businessHours : undefined);
430
+ const eventTrigger = opts.trigger && "event" in opts.trigger ? opts.trigger : undefined;
431
+ const after = eventTrigger?.after;
432
+ const at = this.snap(turn, new Date(turn.now.getTime() + (after ? parseDuration(after) : 0)), eventTrigger?.businessHours);
433
+ // With no `after`, only a closed hour parks the start: inside working hours the snap returns now.
434
+ if (after || at.getTime() > turn.now.getTime()) {
431
435
  this.park(turn, run, { kind: "timer", key: `${run.id}:start:${at.getTime()}` }, at);
432
436
  this.outcome(turn, run, { kind: "wait", status: "waiting", code: "awaiting-trigger", until: at.toISOString() });
433
437
  }
@@ -657,6 +661,9 @@ export class Runner<C = unknown, D = unknown> {
657
661
  }
658
662
  }
659
663
 
664
+ // Unless routing or Ingest moved it, the message is the asker's: written down, so a chain beside it cannot take it.
665
+ if (asker && !route && !turn.floorFromIngest) turn.floorRunId = asker.id;
666
+
660
667
  for (const [field, raw] of Object.entries(understanding?.fields ?? {})) this.writeField(turn, field, raw);
661
668
 
662
669
  if (asker && turn.session.runs.includes(asker) && asker.status === "asking") {
@@ -667,8 +674,15 @@ export class Runner<C = unknown, D = unknown> {
667
674
  }
668
675
 
669
676
  private fireBranch(turn: Turn<C, D>, run: Run, flow: Flow<C, D>, step: TalkOf<C, D>, understanding: Understanding | null): void {
677
+ // Staying, a run does not take an `if` branch back onto a path it has already run: the fact it tests is still true,
678
+ // so it would re-run that path on every message. A `when` branch is judged on each new message and always counts.
679
+ // 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`.
680
+ const ran = (then: Next<D>): boolean =>
681
+ typeof then === "string" ? then === "end" || (run.visits[then] ?? 0) > 0 : "step" in then && (run.visits[then.step] ?? 0) > 0;
670
682
  const hit = (branch: Branch<C, D>, index: number): boolean =>
671
- "if" in branch ? this.holds(branch.if, turn, run) : understanding?.branches[`${run.id}/${step.id}/${index}`] === true;
683
+ "if" in branch
684
+ ? !(run.staying && ran(branch.then)) && this.holds(branch.if, turn, run)
685
+ : understanding?.branches[`${run.id}/${step.id}/${index}`] === true;
672
686
  const index = (step.branches ?? []).findIndex(hit);
673
687
  if (index < 0) return;
674
688
  const branch = (step.branches ?? [])[index];
@@ -698,9 +712,18 @@ export class Runner<C = unknown, D = unknown> {
698
712
 
699
713
  async advance(turn: Turn<C, D>): Promise<TalkRequest<C, D> | IdleRequest<C, D> | null> {
700
714
  if (turn.ignored) return null;
701
- this.resumeSuspended(turn);
715
+ this.resume(turn);
702
716
  turn.queue = [...turn.session.runs];
703
717
  await this.drain(turn);
718
+ // An asker that moved on without a word hands the message to the run it suspended, and that one to the next.
719
+ // Each pass takes a run off the suspended stack, so the stack's size bounds the loop.
720
+ for (let left = turn.session.runs.length; left > 0; left--) {
721
+ if (turn.what.kind !== "message" || turn.silenced !== undefined || turn.talk || turn.spokeBy.size > 0) break;
722
+ const resumed = this.resume(turn);
723
+ if (!resumed) break;
724
+ turn.queue.push(resumed);
725
+ await this.drain(turn);
726
+ }
704
727
  return this.speaker(turn);
705
728
  }
706
729
 
@@ -771,6 +794,11 @@ export class Runner<C = unknown, D = unknown> {
771
794
  return;
772
795
  }
773
796
  if (!this.premiseHolds(turn, run, flow)) return;
797
+ // The flow was edited off 'stay' after this run finished its steps: its new `onEnd` decides now.
798
+ if (run.staying && flow.onEnd !== "stay") {
799
+ this.finishFlow(turn, run, flow);
800
+ return;
801
+ }
774
802
  const resuming = run.status === "asking";
775
803
  run.status = "running";
776
804
  if (run.stepId === null) {
@@ -822,7 +850,9 @@ export class Runner<C = unknown, D = unknown> {
822
850
  let pending: string[] = [];
823
851
  if (step.collect?.length) {
824
852
  pending = pendingFields(step, data, run.asked);
825
- if (!pending.length) {
853
+ // A `stay` run answers even with nothing left to collect: answering is what it stays for. It only runs on a message
854
+ // (asking runs sit out wakes and events), and on a silenced one the check below keeps it asking.
855
+ if (!pending.length && !run.staying) {
826
856
  this.reportMaxAsks(turn, run, step);
827
857
  this.outcome(turn, run, resuming
828
858
  ? { kind, status: "ok", key, next: nextLabel(step.then) }
@@ -991,11 +1021,11 @@ export class Runner<C = unknown, D = unknown> {
991
1021
  const childId = render(target.flow, this.scope(turn, run));
992
1022
  const key = this.stepKey(run, step);
993
1023
  this.endRun(turn, run, "flow");
994
- this.chain(turn, childId, key, target.input ?? run.input, run.hop + 1);
1024
+ this.chain(turn, run, childId, key, target.input ?? run.input, run.hop + 1);
995
1025
  }
996
1026
 
997
- /** Start a child flow that holds the floor and moves in this same phase. */
998
- private chain(turn: Turn<C, D>, flowId: string, key: string, input: unknown, hop: number, keepData = false): void {
1027
+ /** Start a child flow that moves in this same phase. It inherits the floor when its parent held it, or when nobody did. */
1028
+ private chain(turn: Turn<C, D>, parent: Run, flowId: string, key: string, input: unknown, hop: number, keepData = false): void {
999
1029
  const child = this.flows.get(flowId);
1000
1030
  if (!child) {
1001
1031
  turn.skipped.push({ flowId, anchor: turn.session.id, triggerKey: key, code: "flow-gone", message: OUTCOME_MESSAGES["flow-gone"] });
@@ -1003,8 +1033,10 @@ export class Runner<C = unknown, D = unknown> {
1003
1033
  }
1004
1034
  const run = this.startRun(turn, child, "flow", key, { payload: input, hop, keepData });
1005
1035
  if (!run) return;
1006
- turn.floorRunId = run.id;
1007
- if (turn.ingesting) turn.floorFromIngest = true;
1036
+ if (turn.floorRunId === undefined || turn.floorRunId === parent.id) {
1037
+ turn.floorRunId = run.id;
1038
+ if (turn.ingesting) turn.floorFromIngest = true;
1039
+ }
1008
1040
  turn.queue.push(run);
1009
1041
  }
1010
1042
 
@@ -1019,15 +1051,55 @@ export class Runner<C = unknown, D = unknown> {
1019
1051
  run.visits[stepId] = (run.visits[stepId] ?? 0) + 1;
1020
1052
  run.status = "running";
1021
1053
  delete run.waiting;
1054
+ delete run.staying;
1055
+ }
1056
+
1057
+ /** `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. */
1058
+ private stayAt(run: Run, step: Step<C, D>): void {
1059
+ this.enter(run, step.id);
1060
+ run.staying = true;
1061
+ run.status = "asking";
1062
+ }
1063
+
1064
+ /**
1065
+ * 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.
1066
+ * 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
1067
+ * stays on the flow's last talk step. Upgrade: record the talk step on the run when it talks.
1068
+ */
1069
+ private stayStep(run: Run, flow: Flow<C, D>): TalkOf<C, D> | undefined {
1070
+ for (let i = run.outcomes.length - 1; i >= 0; i--) {
1071
+ const { kind, stepId } = run.outcomes[i];
1072
+ const step = kind === "prompt" || kind === "collect" ? this.stepOf(flow, stepId ?? null) : undefined;
1073
+ if (step && isTalk(step)) return step;
1074
+ }
1075
+ return [...flow.steps].reverse().find(isTalk);
1022
1076
  }
1023
1077
 
1024
1078
  private finishFlow(turn: Turn<C, D>, run: Run, flow: Flow<C, D>): void {
1025
1079
  const onEnd = flow.onEnd ?? "end";
1026
1080
  const last = flow.steps[flow.steps.length - 1];
1027
- if (onEnd === "stay" && last) {
1028
- // "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.
1029
- this.enter(run, last.id);
1030
- run.status = "asking";
1081
+ const stay = onEnd === "stay" ? this.stayStep(run, flow) : undefined;
1082
+ if (stay) {
1083
+ // The steps after it ran once, on the way here; staying, it only answers.
1084
+ this.stayAt(run, stay);
1085
+ // One asker at a time. A run the lead's message went to holds the conversation and suspends the other asker;
1086
+ // any other run waits behind that asker and resumes when it is done.
1087
+ const others = turn.session.runs.filter((r) => r !== run && r.status === "asking");
1088
+ if (others.length && !(turn.what.kind === "message" && turn.floorRunId === run.id)) {
1089
+ run.status = "suspended";
1090
+ run.suspendedAt = turn.nowIso;
1091
+ return;
1092
+ }
1093
+ for (const other of others) {
1094
+ other.status = "suspended";
1095
+ other.suspendedAt = turn.nowIso;
1096
+ if (turn.talk?.run === other) turn.talk = undefined;
1097
+ }
1098
+ // A message nothing has answered yet gets its answer now. Reached in Ingest, the run stays asking: Decide judges its
1099
+ // branches as the asker's, and it answers when it moves.
1100
+ if (!turn.ingesting && turn.what.kind === "message" && turn.silenced === undefined && !turn.speakDone && turn.spokeBy.size === 0) {
1101
+ run.status = "running";
1102
+ }
1031
1103
  return;
1032
1104
  }
1033
1105
  if (onEnd === "reset" && last) {
@@ -1035,7 +1107,7 @@ export class Runner<C = unknown, D = unknown> {
1035
1107
  // 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.
1036
1108
  const key = this.stepKey(run, last);
1037
1109
  this.endRun(turn, run, "reset");
1038
- this.chain(turn, flow.id, key, run.input, run.hop + 1, true);
1110
+ this.chain(turn, run, flow.id, key, run.input, run.hop + 1, true);
1039
1111
  return;
1040
1112
  }
1041
1113
  this.endRun(turn, run, "end");
@@ -1058,9 +1130,20 @@ export class Runner<C = unknown, D = unknown> {
1058
1130
  turn.schedule.push({ key: waiting.key, at });
1059
1131
  }
1060
1132
 
1133
+ /** `resumeSuspended`, and on a message the resumed run's `if` branches are judged before it speaks, as the asker's are in Decide. */
1134
+ private resume(turn: Turn<C, D>): Run | undefined {
1135
+ const run = this.resumeSuspended(turn);
1136
+ if (run && turn.what.kind === "message") {
1137
+ const flow = this.flows.get(run.flowId);
1138
+ const step = flow && this.stepOf(flow, run.stepId);
1139
+ if (flow && step && isTalk(step)) this.fireBranch(turn, run, flow, step, null);
1140
+ }
1141
+ return run;
1142
+ }
1143
+
1061
1144
  /** When nobody asks, the most recently suspended run returns to asking. */
1062
- private resumeSuspended(turn: Turn<C, D>): void {
1063
- if (this.asker(turn)) return;
1145
+ private resumeSuspended(turn: Turn<C, D>): Run | undefined {
1146
+ if (this.asker(turn)) return undefined;
1064
1147
  const suspendedAt = (r: Run): string => r.suspendedAt ?? r.startedAt;
1065
1148
  const next = turn.session.runs
1066
1149
  .filter((r) => r.status === "suspended")
@@ -1069,6 +1152,7 @@ export class Runner<C = unknown, D = unknown> {
1069
1152
  next.status = "asking";
1070
1153
  delete next.suspendedAt;
1071
1154
  }
1155
+ return next;
1072
1156
  }
1073
1157
 
1074
1158
  // ── Settle: the one applier after Speak (design §4.7) ─────────────────
@@ -1118,15 +1202,22 @@ export class Runner<C = unknown, D = unknown> {
1118
1202
  Object.assign(turn.session.data, spoken.data);
1119
1203
  this.outcome(turn, run, { kind: talkKind(step), status: "ok", key, llmCalls: spoken.llmCalls, stepId: step.id });
1120
1204
  if (!turn.session.runs.includes(run)) return;
1121
- if (step.collect?.length) {
1122
- const pending = pendingFields(step, turn.session.data, run.asked);
1123
- if (pending.length) {
1124
- for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
1125
- run.status = "asking";
1126
- return;
1205
+ const pending = step.collect?.length ? pendingFields(step, turn.session.data, run.asked) : [];
1206
+ for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
1207
+ if (run.staying) {
1208
+ // 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.
1209
+ const maxAsks = step.maxAsks ?? DEFAULT_MAX_ASKS;
1210
+ for (const field of pending) {
1211
+ if (run.asked[field] === maxAsks) this.outcome(turn, run, { kind: "collect", status: "skipped", key, code: "max-asks", detail: field });
1127
1212
  }
1128
- this.reportMaxAsks(turn, run, step);
1213
+ this.stayAt(run, step);
1214
+ return;
1215
+ }
1216
+ if (pending.length) {
1217
+ run.status = "asking";
1218
+ return;
1129
1219
  }
1220
+ this.reportMaxAsks(turn, run, step);
1130
1221
  run.status = "running";
1131
1222
  this.follow(turn, run, flow, step, step.then);
1132
1223
  if (turn.session.runs.includes(run) && run.status === "running") turn.queue.push(run);
package/src/core/Speak.ts CHANGED
@@ -96,6 +96,8 @@ const WIRE_NAME = /^[a-zA-Z0-9_-]+$/;
96
96
  const GUIDELINE_HEADING = "## Guideline for your reply (adapt to the conversation)";
97
97
  const DEFAULT_GUIDELINE =
98
98
  "Collect what is still missing below, in the flow of the conversation, one or two things per message.";
99
+ /** A step with no prompt and nothing left to collect: the step an `onEnd: 'stay'` run answers from. */
100
+ const ANSWER_GUIDELINE = "Answer the customer's message, in the flow of the conversation.";
99
101
  const TOOLS_SECTION =
100
102
  "## Tools\nCall the tools provided when you need to look something up or act before answering. Once you have what you need, answer the customer.";
101
103
  const FINAL_SECTION =
@@ -330,7 +332,7 @@ function buildPrompt<C, D>(
330
332
  ? [guideline(talk.idle.prompt)]
331
333
  : [
332
334
  `## Flow\n${talk.flow.name}${talk.flow.description ? `: ${talk.flow.description}` : ""}`,
333
- guideline(talk.step.prompt ?? DEFAULT_GUIDELINE),
335
+ guideline(talk.step.prompt ?? (talk.pending.length ? DEFAULT_GUIDELINE : ANSWER_GUIDELINE)),
334
336
  pendingSection(talk.pending, options.fields, talk.step.ask ?? {}, scope),
335
337
  // `Partial<D>` is a mapped type; the guard is how it reaches an index-signature parameter without a cast.
336
338
  factsSection(options.fields, isRecord(req.data) ? req.data : {}),
package/src/types/flow.ts CHANGED
@@ -255,7 +255,7 @@ export interface Flow<C = unknown, D = unknown> {
255
255
  while?: Pred<C, D>;
256
256
  clearOnStart?: (keyof D & string)[];
257
257
  steps: Step<C, D>[];
258
- /** What the run does after its last step. Default `'end'`. */
258
+ /** What the run does after its last step. Default `'end'`. `'stay'`: the last talk step the run took answers every later message. */
259
259
  onEnd?: "end" | "stay" | "reset";
260
260
  instructions?: Instruction<C, D>[];
261
261
  tools?: string[];
@@ -28,6 +28,8 @@ export interface Run {
28
28
  startedAt: string;
29
29
  /** Set while `suspended`; the most recently suspended run resumes first. */
30
30
  suspendedAt?: string;
31
+ /** Set once an `onEnd: 'stay'` run has finished its steps: it sits on its last talk step and answers every message from there. Any other move clears it. */
32
+ staying?: true;
31
33
  waiting?: {
32
34
  kind: "timer" | "event";
33
35
  /** The wake key; only a `wake` equal to it is honoured. */