@falai/agent 4.0.0-alpha.11 → 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 (49) 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 +115 -23
  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 +115 -23
  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/migration/v3-to-v4.md +1 -1
  38. package/docs/reference/branches.md +1 -1
  39. package/docs/reference/flow.md +1 -1
  40. package/docs/reference/session.md +2 -0
  41. package/docs/reference/step.md +2 -2
  42. package/examples/05-branches.ts +1 -1
  43. package/package.json +1 -1
  44. package/src/core/FlowSpec.ts +1 -1
  45. package/src/core/Migrate.ts +2 -1
  46. package/src/core/Runner.ts +111 -22
  47. package/src/core/Speak.ts +3 -1
  48. package/src/types/flow.ts +1 -1
  49. package/src/types/session.ts +2 -0
@@ -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
 
@@ -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.11",
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
  }
@@ -659,6 +661,9 @@ export class Runner<C = unknown, D = unknown> {
659
661
  }
660
662
  }
661
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
+
662
667
  for (const [field, raw] of Object.entries(understanding?.fields ?? {})) this.writeField(turn, field, raw);
663
668
 
664
669
  if (asker && turn.session.runs.includes(asker) && asker.status === "asking") {
@@ -669,8 +674,15 @@ export class Runner<C = unknown, D = unknown> {
669
674
  }
670
675
 
671
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;
672
682
  const hit = (branch: Branch<C, D>, index: number): boolean =>
673
- "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;
674
686
  const index = (step.branches ?? []).findIndex(hit);
675
687
  if (index < 0) return;
676
688
  const branch = (step.branches ?? [])[index];
@@ -700,9 +712,18 @@ export class Runner<C = unknown, D = unknown> {
700
712
 
701
713
  async advance(turn: Turn<C, D>): Promise<TalkRequest<C, D> | IdleRequest<C, D> | null> {
702
714
  if (turn.ignored) return null;
703
- this.resumeSuspended(turn);
715
+ this.resume(turn);
704
716
  turn.queue = [...turn.session.runs];
705
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
+ }
706
727
  return this.speaker(turn);
707
728
  }
708
729
 
@@ -773,6 +794,11 @@ export class Runner<C = unknown, D = unknown> {
773
794
  return;
774
795
  }
775
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
+ }
776
802
  const resuming = run.status === "asking";
777
803
  run.status = "running";
778
804
  if (run.stepId === null) {
@@ -824,7 +850,9 @@ export class Runner<C = unknown, D = unknown> {
824
850
  let pending: string[] = [];
825
851
  if (step.collect?.length) {
826
852
  pending = pendingFields(step, data, run.asked);
827
- 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) {
828
856
  this.reportMaxAsks(turn, run, step);
829
857
  this.outcome(turn, run, resuming
830
858
  ? { kind, status: "ok", key, next: nextLabel(step.then) }
@@ -993,11 +1021,11 @@ export class Runner<C = unknown, D = unknown> {
993
1021
  const childId = render(target.flow, this.scope(turn, run));
994
1022
  const key = this.stepKey(run, step);
995
1023
  this.endRun(turn, run, "flow");
996
- 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);
997
1025
  }
998
1026
 
999
- /** Start a child flow that holds the floor and moves in this same phase. */
1000
- 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 {
1001
1029
  const child = this.flows.get(flowId);
1002
1030
  if (!child) {
1003
1031
  turn.skipped.push({ flowId, anchor: turn.session.id, triggerKey: key, code: "flow-gone", message: OUTCOME_MESSAGES["flow-gone"] });
@@ -1005,8 +1033,10 @@ export class Runner<C = unknown, D = unknown> {
1005
1033
  }
1006
1034
  const run = this.startRun(turn, child, "flow", key, { payload: input, hop, keepData });
1007
1035
  if (!run) return;
1008
- turn.floorRunId = run.id;
1009
- 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
+ }
1010
1040
  turn.queue.push(run);
1011
1041
  }
1012
1042
 
@@ -1021,15 +1051,55 @@ export class Runner<C = unknown, D = unknown> {
1021
1051
  run.visits[stepId] = (run.visits[stepId] ?? 0) + 1;
1022
1052
  run.status = "running";
1023
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);
1024
1076
  }
1025
1077
 
1026
1078
  private finishFlow(turn: Turn<C, D>, run: Run, flow: Flow<C, D>): void {
1027
1079
  const onEnd = flow.onEnd ?? "end";
1028
1080
  const last = flow.steps[flow.steps.length - 1];
1029
- if (onEnd === "stay" && last) {
1030
- // "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.
1031
- this.enter(run, last.id);
1032
- 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
+ }
1033
1103
  return;
1034
1104
  }
1035
1105
  if (onEnd === "reset" && last) {
@@ -1037,7 +1107,7 @@ export class Runner<C = unknown, D = unknown> {
1037
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.
1038
1108
  const key = this.stepKey(run, last);
1039
1109
  this.endRun(turn, run, "reset");
1040
- 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);
1041
1111
  return;
1042
1112
  }
1043
1113
  this.endRun(turn, run, "end");
@@ -1060,9 +1130,20 @@ export class Runner<C = unknown, D = unknown> {
1060
1130
  turn.schedule.push({ key: waiting.key, at });
1061
1131
  }
1062
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
+
1063
1144
  /** When nobody asks, the most recently suspended run returns to asking. */
1064
- private resumeSuspended(turn: Turn<C, D>): void {
1065
- if (this.asker(turn)) return;
1145
+ private resumeSuspended(turn: Turn<C, D>): Run | undefined {
1146
+ if (this.asker(turn)) return undefined;
1066
1147
  const suspendedAt = (r: Run): string => r.suspendedAt ?? r.startedAt;
1067
1148
  const next = turn.session.runs
1068
1149
  .filter((r) => r.status === "suspended")
@@ -1071,6 +1152,7 @@ export class Runner<C = unknown, D = unknown> {
1071
1152
  next.status = "asking";
1072
1153
  delete next.suspendedAt;
1073
1154
  }
1155
+ return next;
1074
1156
  }
1075
1157
 
1076
1158
  // ── Settle: the one applier after Speak (design §4.7) ─────────────────
@@ -1120,15 +1202,22 @@ export class Runner<C = unknown, D = unknown> {
1120
1202
  Object.assign(turn.session.data, spoken.data);
1121
1203
  this.outcome(turn, run, { kind: talkKind(step), status: "ok", key, llmCalls: spoken.llmCalls, stepId: step.id });
1122
1204
  if (!turn.session.runs.includes(run)) return;
1123
- if (step.collect?.length) {
1124
- const pending = pendingFields(step, turn.session.data, run.asked);
1125
- if (pending.length) {
1126
- for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
1127
- run.status = "asking";
1128
- 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 });
1129
1212
  }
1130
- this.reportMaxAsks(turn, run, step);
1213
+ this.stayAt(run, step);
1214
+ return;
1215
+ }
1216
+ if (pending.length) {
1217
+ run.status = "asking";
1218
+ return;
1131
1219
  }
1220
+ this.reportMaxAsks(turn, run, step);
1132
1221
  run.status = "running";
1133
1222
  this.follow(turn, run, flow, step, step.then);
1134
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. */