@falai/agent 4.0.0-alpha.11 → 4.0.0-alpha.13

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 (76) hide show
  1. package/dist/cjs/core/FlowSpec.d.ts +2 -0
  2. package/dist/cjs/core/FlowSpec.d.ts.map +1 -1
  3. package/dist/cjs/core/FlowSpec.js +26 -3
  4. package/dist/cjs/core/FlowSpec.js.map +1 -1
  5. package/dist/cjs/core/Migrate.d.ts.map +1 -1
  6. package/dist/cjs/core/Migrate.js +3 -1
  7. package/dist/cjs/core/Migrate.js.map +1 -1
  8. package/dist/cjs/core/Runner.d.ts +17 -1
  9. package/dist/cjs/core/Runner.d.ts.map +1 -1
  10. package/dist/cjs/core/Runner.js +178 -35
  11. package/dist/cjs/core/Runner.js.map +1 -1
  12. package/dist/cjs/core/Speak.d.ts.map +1 -1
  13. package/dist/cjs/core/Speak.js +3 -1
  14. package/dist/cjs/core/Speak.js.map +1 -1
  15. package/dist/cjs/types/flow.d.ts +15 -1
  16. package/dist/cjs/types/flow.d.ts.map +1 -1
  17. package/dist/cjs/types/session.d.ts +3 -1
  18. package/dist/cjs/types/session.d.ts.map +1 -1
  19. package/dist/cjs/utils/outcomes.d.ts +1 -0
  20. package/dist/cjs/utils/outcomes.d.ts.map +1 -1
  21. package/dist/cjs/utils/outcomes.js +1 -0
  22. package/dist/cjs/utils/outcomes.js.map +1 -1
  23. package/dist/cjs/utils/schema.d.ts +3 -3
  24. package/dist/cjs/utils/schema.d.ts.map +1 -1
  25. package/dist/cjs/utils/schema.js +4 -3
  26. package/dist/cjs/utils/schema.js.map +1 -1
  27. package/dist/core/FlowSpec.d.ts +2 -0
  28. package/dist/core/FlowSpec.d.ts.map +1 -1
  29. package/dist/core/FlowSpec.js +27 -4
  30. package/dist/core/FlowSpec.js.map +1 -1
  31. package/dist/core/Migrate.d.ts.map +1 -1
  32. package/dist/core/Migrate.js +3 -1
  33. package/dist/core/Migrate.js.map +1 -1
  34. package/dist/core/Runner.d.ts +17 -1
  35. package/dist/core/Runner.d.ts.map +1 -1
  36. package/dist/core/Runner.js +178 -35
  37. package/dist/core/Runner.js.map +1 -1
  38. package/dist/core/Speak.d.ts.map +1 -1
  39. package/dist/core/Speak.js +3 -1
  40. package/dist/core/Speak.js.map +1 -1
  41. package/dist/types/flow.d.ts +15 -1
  42. package/dist/types/flow.d.ts.map +1 -1
  43. package/dist/types/session.d.ts +3 -1
  44. package/dist/types/session.d.ts.map +1 -1
  45. package/dist/utils/outcomes.d.ts +1 -0
  46. package/dist/utils/outcomes.d.ts.map +1 -1
  47. package/dist/utils/outcomes.js +1 -0
  48. package/dist/utils/outcomes.js.map +1 -1
  49. package/dist/utils/schema.d.ts +3 -3
  50. package/dist/utils/schema.d.ts.map +1 -1
  51. package/dist/utils/schema.js +4 -3
  52. package/dist/utils/schema.js.map +1 -1
  53. package/docs/concepts/collection.md +40 -5
  54. package/docs/concepts/pipeline.md +6 -5
  55. package/docs/concepts/runs-and-waits.md +1 -1
  56. package/docs/guides/branching.md +3 -1
  57. package/docs/guides/flow-control.md +3 -1
  58. package/docs/migration/v3-to-v4.md +1 -1
  59. package/docs/reference/branches.md +1 -1
  60. package/docs/reference/fields.md +5 -3
  61. package/docs/reference/flow-spec.md +6 -3
  62. package/docs/reference/flow.md +8 -3
  63. package/docs/reference/outcomes.md +2 -1
  64. package/docs/reference/session.md +2 -0
  65. package/docs/reference/step.md +7 -4
  66. package/docs/rfc/v4-one-flow.md +2 -0
  67. package/examples/05-branches.ts +1 -1
  68. package/package.json +1 -1
  69. package/src/core/FlowSpec.ts +33 -5
  70. package/src/core/Migrate.ts +2 -1
  71. package/src/core/Runner.ts +167 -32
  72. package/src/core/Speak.ts +3 -1
  73. package/src/types/flow.ts +15 -1
  74. package/src/types/session.ts +3 -0
  75. package/src/utils/outcomes.ts +1 -0
  76. package/src/utils/schema.ts +4 -3
@@ -44,7 +44,7 @@ import type {
44
44
  import type { StructuredSchema } from "../types/schema.js";
45
45
  import { isDuration } from "../utils/duration.js";
46
46
  import { splitPhrases } from "../utils/phrases.js";
47
- import { toWireSchema } from "../utils/schema.js";
47
+ import { extractMode, toWireSchema } from "../utils/schema.js";
48
48
 
49
49
  // ── The JSON form ───────────────────────────────────────────────────────
50
50
 
@@ -77,7 +77,7 @@ interface TalkSpecExtras {
77
77
  export type StepSpec = StepBase<LooseData> &
78
78
  (
79
79
  | ({ kind: "prompt"; prompt: Template; collect?: undefined } & TalkSpecExtras)
80
- | ({ kind: "collect"; collect: string[]; prompt?: Template } & TalkSpecExtras)
80
+ | ({ kind: "collect"; collect: string[]; prompt?: Template; question?: Template } & TalkSpecExtras)
81
81
  | ({ kind: "say" } & SayStep)
82
82
  | ({ kind: "do" } & DoStep<LooseData>)
83
83
  | { kind: "wait"; wait: Duration; businessHours?: boolean; else?: Next<LooseData>; branches?: BranchSpec[] }
@@ -92,6 +92,7 @@ export interface FlowSpec {
92
92
  on?: TriggerSpec[];
93
93
  anchor?: string;
94
94
  while?: ConditionSpec<LooseData>;
95
+ collect?: string[];
95
96
  clearOnStart?: string[];
96
97
  steps: StepSpec[];
97
98
  onEnd?: "end" | "stay" | "reset";
@@ -136,6 +137,7 @@ export function toSpec<C, D extends LooseData>(flow: Flow<C, D>): FlowSpec {
136
137
  on: flow.on?.map((trigger, i) => triggerToSpec(trigger, at(`trigger #${i + 1}`))),
137
138
  anchor: flow.anchor,
138
139
  while: jsonPred(flow.while, at("while")),
140
+ collect: flow.collect,
139
141
  clearOnStart: flow.clearOnStart,
140
142
  steps: flow.steps.map((step) => stepToSpec(step, at(`step "${step.id}"`))),
141
143
  onEnd: flow.onEnd,
@@ -189,11 +191,12 @@ function stepToSpec<C, D extends LooseData>(step: Step<C, D>, at: string): StepS
189
191
  instructions: step.instructions?.map((ins, i) => instructionToSpec(ins, `${at} instructions[${i}]`)),
190
192
  };
191
193
  if (step.collect !== undefined) {
192
- return compact<StepSpec>({ ...base, kind: "collect", collect: step.collect, prompt: step.prompt, ...talk });
194
+ return compact<StepSpec>({ ...base, kind: "collect", collect: step.collect, prompt: step.prompt, question: step.question, ...talk });
193
195
  }
194
196
  if (step.prompt === undefined) {
195
197
  throw problem(at, "has neither prompt nor collect", "A talk step needs a guideline, fields to collect, or both.");
196
198
  }
199
+ if (step.question !== undefined) throw questionWithoutCollect(at);
197
200
  return compact<StepSpec>({ ...base, kind: "prompt", prompt: step.prompt, ...talk });
198
201
  }
199
202
 
@@ -248,6 +251,7 @@ interface LooseStep {
248
251
  onFail?: Next<LooseData>;
249
252
  prompt?: Template;
250
253
  collect?: string[];
254
+ question?: Template;
251
255
  ask?: Partial<Record<string, string>>;
252
256
  branches?: LooseBranch[];
253
257
  instructions?: LooseInstruction[];
@@ -262,6 +266,7 @@ interface LooseFlow {
262
266
  id: string;
263
267
  on?: LooseTrigger[];
264
268
  while?: LoosePred;
269
+ collect?: string[];
265
270
  clearOnStart?: string[];
266
271
  steps: LooseStep[];
267
272
  instructions?: LooseInstruction[];
@@ -421,7 +426,18 @@ export function validateFlow<C = unknown, D = LooseData>(
421
426
  }
422
427
  };
423
428
 
429
+ for (const field of flow.collect ?? []) slug(field, flowAt, "collect");
424
430
  for (const field of flow.clearOnStart ?? []) slug(field, flowAt, "clearOnStart");
431
+ // A field taken only from the answer to a step that asks it can never be filled when no step asks it.
432
+ const asked = new Set(flow.steps.flatMap((step) => step.collect ?? []));
433
+ for (const field of flow.collect ?? []) {
434
+ if (!asked.has(field) && extractMode(fields[field]) === "asked") {
435
+ warnings.push(
436
+ `${flowAt}: collect lists "${field}", which is only taken from the answer to a step that asks it, and no ` +
437
+ "step does. Add it to a step's collect, or set extract: 'anywhere' on the field.",
438
+ );
439
+ }
440
+ }
425
441
  pred(flow.while, flowAt, "while");
426
442
  toolNames(flow.tools, flowAt);
427
443
  flow.instructions?.forEach((ins, i) => pred(ins.if, flowAt, `instructions[${i}].if`));
@@ -483,8 +499,14 @@ export function validateFlow<C = unknown, D = LooseData>(
483
499
  if (step.collect !== undefined || step.prompt !== undefined) {
484
500
  for (const field of step.collect ?? []) slug(field, at, "collect");
485
501
  for (const field of Object.keys(step.ask ?? {})) slug(field, at, "ask");
502
+ if (step.question !== undefined && !step.collect?.length) throw questionWithoutCollect(at);
486
503
  const fields_ = step.collect ?? [];
487
- if (step.prompt === undefined && fields_.length > 0 && fields_.every((f) => !step.ask?.[f] && !fields[f].ask)) {
504
+ if (
505
+ step.prompt === undefined &&
506
+ step.question === undefined &&
507
+ fields_.length > 0 &&
508
+ fields_.every((f) => !step.ask?.[f] && !fields[f].ask)
509
+ ) {
488
510
  warnings.push(
489
511
  `${at}: collects ${fields_.map((f) => `"${f}"`).join(", ")} with no prompt and no ask; the AI has nothing ` +
490
512
  "to go on. Add a prompt or an ask per field.",
@@ -655,6 +677,7 @@ export function flowSpecSchema(registries: Registries): StructuredSchema {
655
677
  kind: enumOf(["collect"]),
656
678
  collect: { ...slugList, description: "Fields the AI asks for until they are known" },
657
679
  prompt: orNull(STRING),
680
+ question: orNull({ type: "string", description: "A fixed first question, sent word for word; later asks are the AI's" }),
658
681
  maxAsks: orNull({ ...INTEGER, description: "Times a field may be asked before it is skipped; default 3" }),
659
682
  branches,
660
683
  }),
@@ -700,9 +723,10 @@ export function flowSpecSchema(registries: Registries): StructuredSchema {
700
723
  on: orNull(list(trigger, "What starts a run; null = started by hand")),
701
724
  anchor: orNull({ type: "string", description: "'session' (default) or a host anchor such as 'lead'" }),
702
725
  while: orNull({ ...condition, description: "The run ends when this stops holding" }),
726
+ collect: slugList && orNull({ ...slugList, description: "The data this flow needs; its steps ask for it in order, and any of it the lead gives is noted" }),
703
727
  clearOnStart: slugList && orNull({ ...slugList, description: "Fields to forget when a run starts" }),
704
728
  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")),
729
+ 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
730
  instructions: orNull(list(instruction, "Rules that apply only inside this flow")),
707
731
  });
708
732
  }
@@ -751,6 +775,10 @@ function orNull(schema: StructuredSchema): StructuredSchema {
751
775
 
752
776
  // ── Shared helpers ──────────────────────────────────────────────────────
753
777
 
778
+ function questionWithoutCollect(at: string): FlowConfigurationError {
779
+ return problem(at, "has a question but collects nothing", "A fixed question asks for fields: add collect, or send the text with a say step.");
780
+ }
781
+
754
782
  function problem(at: string, what: string, fix: string): FlowConfigurationError {
755
783
  return new FlowConfigurationError(`[FlowConfigurationError] ${at}: ${what}. ${fix}`);
756
784
  }
@@ -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
 
@@ -137,6 +137,13 @@ function talkKind(step: { collect?: readonly string[] }): StepOutcomeKind {
137
137
  return step.collect?.length ? "collect" : "prompt";
138
138
  }
139
139
 
140
+ /** Everything a flow collects: its own `collect`, then each talk step's, once each. */
141
+ function flowFields<C, D>(flow: Flow<C, D>): string[] {
142
+ const fields = new Set<string>(flow.collect);
143
+ for (const step of flow.steps) if (isTalk(step)) for (const field of step.collect ?? []) fields.add(field);
144
+ return [...fields];
145
+ }
146
+
140
147
  function kindOf<C, D>(step: Step<C, D> | undefined): StepOutcomeKind {
141
148
  if (!step) return "if";
142
149
  if (isTalk(step)) return talkKind(step);
@@ -303,6 +310,8 @@ export class Runner<C = unknown, D = unknown> {
303
310
  const branch = step.branches?.find((b) => "if" in b && this.holds(b.if, turn, run));
304
311
  const target = branch ? branch.then : step.else;
305
312
  this.outcome(turn, run, { kind: "wait", status: "ok", code: "replied", next: nextLabel(target) });
313
+ // The reply is this run's while it moves, so a stay it reaches answers it.
314
+ if (!turn.floorFromIngest) turn.floorRunId = run.id;
306
315
  this.follow(turn, run, flow, step, target);
307
316
  this.takeFloor(turn, run);
308
317
  }
@@ -541,16 +550,25 @@ export class Runner<C = unknown, D = unknown> {
541
550
  }
542
551
  const fields: UnderstandRequest<C, D>["fields"] = {};
543
552
  const data: Record<string, unknown> = turn.session.data;
553
+ const harvestable = (field: string): boolean => {
554
+ const def = this.options.fields[field];
555
+ return def !== undefined && !isKnown(data[field]) && extractMode(def) === "anywhere";
556
+ };
544
557
  for (const flow of [...(floorFlow ? [floorFlow] : []), ...eligible]) {
545
- for (const step of flow.steps) {
546
- if (!isTalk(step)) continue;
547
- for (const field of step.collect ?? []) {
548
- const def = this.options.fields[field];
549
- if (def && !isKnown(data[field]) && extractMode(def) === "anywhere") fields[field] = def;
550
- }
551
- }
558
+ for (const field of flowFields(flow).filter(harvestable)) fields[field] = this.options.fields[field];
559
+ }
560
+ const judging = messageFlows.length > 0 || mentionFlows.length > 0 || branches.length > 0 || Object.keys(fields).length > 0;
561
+ // With nobody on the floor the catch-all may take this message, and an opening message often says the most. The
562
+ // 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.
563
+ // 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.
564
+ const fallback = floorRun ? undefined : this.messageFlows(turn, (list) => list.length === 0)[0];
565
+ if (fallback) {
566
+ const first = fallback.steps[0];
567
+ const free = new Set<string>(first && isTalk(first) && first.question === undefined ? first.collect : []);
568
+ const own = flowFields(fallback).filter(harvestable);
569
+ if (judging || own.some((field) => !free.has(field))) for (const field of own) fields[field] = this.options.fields[field];
552
570
  }
553
- if (!messageFlows.length && !mentionFlows.length && !branches.length && !Object.keys(fields).length) return null;
571
+ if (!judging && !Object.keys(fields).length) return null;
554
572
  return {
555
573
  text: turn.what.text,
556
574
  history: this.historyOf(turn),
@@ -659,6 +677,9 @@ export class Runner<C = unknown, D = unknown> {
659
677
  }
660
678
  }
661
679
 
680
+ // Unless routing or Ingest moved it, the message is the asker's: written down, so a chain beside it cannot take it.
681
+ if (asker && !route && !turn.floorFromIngest) turn.floorRunId = asker.id;
682
+
662
683
  for (const [field, raw] of Object.entries(understanding?.fields ?? {})) this.writeField(turn, field, raw);
663
684
 
664
685
  if (asker && turn.session.runs.includes(asker) && asker.status === "asking") {
@@ -669,8 +690,15 @@ export class Runner<C = unknown, D = unknown> {
669
690
  }
670
691
 
671
692
  private fireBranch(turn: Turn<C, D>, run: Run, flow: Flow<C, D>, step: TalkOf<C, D>, understanding: Understanding | null): void {
693
+ // Staying, a run does not take an `if` branch back onto a path it has already run: the fact it tests is still true,
694
+ // so it would re-run that path on every message. A `when` branch is judged on each new message and always counts.
695
+ // 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`.
696
+ const ran = (then: Next<D>): boolean =>
697
+ typeof then === "string" ? then === "end" || (run.visits[then] ?? 0) > 0 : "step" in then && (run.visits[then.step] ?? 0) > 0;
672
698
  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;
699
+ "if" in branch
700
+ ? !(run.staying && ran(branch.then)) && this.holds(branch.if, turn, run)
701
+ : understanding?.branches[`${run.id}/${step.id}/${index}`] === true;
674
702
  const index = (step.branches ?? []).findIndex(hit);
675
703
  if (index < 0) return;
676
704
  const branch = (step.branches ?? [])[index];
@@ -700,10 +728,43 @@ export class Runner<C = unknown, D = unknown> {
700
728
 
701
729
  async advance(turn: Turn<C, D>): Promise<TalkRequest<C, D> | IdleRequest<C, D> | null> {
702
730
  if (turn.ignored) return null;
703
- this.resumeSuspended(turn);
731
+ this.resume(turn);
704
732
  turn.queue = [...turn.session.runs];
705
733
  await this.drain(turn);
706
- return this.speaker(turn);
734
+ // An asker that moved on without a word hands the message to the run it suspended, and that one to the next.
735
+ // Each pass takes a run off the suspended stack, so the stack's size bounds the loop.
736
+ for (let left = turn.session.runs.length; left > 0; left--) {
737
+ if (turn.what.kind !== "message" || turn.silenced !== undefined || turn.talk || turn.spokeBy.size > 0) break;
738
+ const resumed = this.resume(turn);
739
+ if (!resumed) break;
740
+ turn.queue.push(resumed);
741
+ await this.drain(turn);
742
+ }
743
+ const speaker = this.speaker(turn);
744
+ if (speaker && !("idle" in speaker) && this.askFixed(turn, speaker)) {
745
+ turn.talk = undefined;
746
+ return null;
747
+ }
748
+ return speaker;
749
+ }
750
+
751
+ /**
752
+ * Send the step's fixed question when this is its first ask: every field it collects still unknown, none asked yet,
753
+ * and the run not staying (a staying run answers the lead). The question counts as one ask of each field. False when
754
+ * the AI should phrase the ask instead.
755
+ */
756
+ private askFixed(turn: Turn<C, D>, { run, step, pending }: TalkRequest<C, D>): boolean {
757
+ const { question, collect = [] } = step;
758
+ if (question === undefined || run.staying) return false;
759
+ if (pending.length !== collect.length || pending.some((field) => (run.asked[field] ?? 0) > 0)) return false;
760
+ const key = this.stepKey(run, step);
761
+ turn.messages.push({ text: render(question, this.scope(turn, run)), kind: "verbatim", afterMs: turn.afterMs, key, runId: run.id, stepId: step.id });
762
+ turn.afterMs = 0;
763
+ turn.spokeBy.add(run.id);
764
+ turn.spoke = true;
765
+ for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
766
+ this.outcome(turn, run, { kind: "collect", status: "ok", key, code: "asked-fixed", stepId: step.id });
767
+ return true;
707
768
  }
708
769
 
709
770
  /**
@@ -773,6 +834,11 @@ export class Runner<C = unknown, D = unknown> {
773
834
  return;
774
835
  }
775
836
  if (!this.premiseHolds(turn, run, flow)) return;
837
+ // The flow was edited off 'stay' after this run finished its steps: its new `onEnd` decides now.
838
+ if (run.staying && flow.onEnd !== "stay") {
839
+ this.finishFlow(turn, run, flow);
840
+ return;
841
+ }
776
842
  const resuming = run.status === "asking";
777
843
  run.status = "running";
778
844
  if (run.stepId === null) {
@@ -824,7 +890,9 @@ export class Runner<C = unknown, D = unknown> {
824
890
  let pending: string[] = [];
825
891
  if (step.collect?.length) {
826
892
  pending = pendingFields(step, data, run.asked);
827
- if (!pending.length) {
893
+ // A `stay` run answers even with nothing left to collect: answering is what it stays for. It only runs on a message
894
+ // (asking runs sit out wakes and events), and on a silenced one the check below keeps it asking.
895
+ if (!pending.length && !run.staying) {
828
896
  this.reportMaxAsks(turn, run, step);
829
897
  this.outcome(turn, run, resuming
830
898
  ? { kind, status: "ok", key, next: nextLabel(step.then) }
@@ -845,7 +913,9 @@ export class Runner<C = unknown, D = unknown> {
845
913
  }
846
914
  }
847
915
  run.status = "asking";
916
+ // 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.
848
917
  if (!turn.speakDone) turn.talk = { run, flow, step, pending };
918
+ else this.askFixed(turn, { run, flow, step, pending });
849
919
  return;
850
920
  }
851
921
 
@@ -986,18 +1056,22 @@ export class Runner<C = unknown, D = unknown> {
986
1056
  }
987
1057
  if ("step" in target) {
988
1058
  const data: Record<string, unknown> = turn.session.data;
989
- for (const field of target.clear ?? []) delete data[field];
1059
+ // Forgetting a field means asking for it from scratch: its ask count goes too, so a fixed question goes out again.
1060
+ for (const field of target.clear ?? []) {
1061
+ delete data[field];
1062
+ delete run.asked[field];
1063
+ }
990
1064
  this.jump(turn, run, flow, target.step);
991
1065
  return;
992
1066
  }
993
1067
  const childId = render(target.flow, this.scope(turn, run));
994
1068
  const key = this.stepKey(run, step);
995
1069
  this.endRun(turn, run, "flow");
996
- this.chain(turn, childId, key, target.input ?? run.input, run.hop + 1);
1070
+ this.chain(turn, run, childId, key, target.input ?? run.input, run.hop + 1);
997
1071
  }
998
1072
 
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 {
1073
+ /** Start a child flow that moves in this same phase. It inherits the floor when its parent held it, or when nobody did. */
1074
+ private chain(turn: Turn<C, D>, parent: Run, flowId: string, key: string, input: unknown, hop: number, keepData = false): void {
1001
1075
  const child = this.flows.get(flowId);
1002
1076
  if (!child) {
1003
1077
  turn.skipped.push({ flowId, anchor: turn.session.id, triggerKey: key, code: "flow-gone", message: OUTCOME_MESSAGES["flow-gone"] });
@@ -1005,8 +1079,10 @@ export class Runner<C = unknown, D = unknown> {
1005
1079
  }
1006
1080
  const run = this.startRun(turn, child, "flow", key, { payload: input, hop, keepData });
1007
1081
  if (!run) return;
1008
- turn.floorRunId = run.id;
1009
- if (turn.ingesting) turn.floorFromIngest = true;
1082
+ if (turn.floorRunId === undefined || turn.floorRunId === parent.id) {
1083
+ turn.floorRunId = run.id;
1084
+ if (turn.ingesting) turn.floorFromIngest = true;
1085
+ }
1010
1086
  turn.queue.push(run);
1011
1087
  }
1012
1088
 
@@ -1021,15 +1097,55 @@ export class Runner<C = unknown, D = unknown> {
1021
1097
  run.visits[stepId] = (run.visits[stepId] ?? 0) + 1;
1022
1098
  run.status = "running";
1023
1099
  delete run.waiting;
1100
+ delete run.staying;
1101
+ }
1102
+
1103
+ /** `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. */
1104
+ private stayAt(run: Run, step: Step<C, D>): void {
1105
+ this.enter(run, step.id);
1106
+ run.staying = true;
1107
+ run.status = "asking";
1108
+ }
1109
+
1110
+ /**
1111
+ * 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.
1112
+ * 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
1113
+ * stays on the flow's last talk step. Upgrade: record the talk step on the run when it talks.
1114
+ */
1115
+ private stayStep(run: Run, flow: Flow<C, D>): TalkOf<C, D> | undefined {
1116
+ for (let i = run.outcomes.length - 1; i >= 0; i--) {
1117
+ const { kind, stepId } = run.outcomes[i];
1118
+ const step = kind === "prompt" || kind === "collect" ? this.stepOf(flow, stepId ?? null) : undefined;
1119
+ if (step && isTalk(step)) return step;
1120
+ }
1121
+ return [...flow.steps].reverse().find(isTalk);
1024
1122
  }
1025
1123
 
1026
1124
  private finishFlow(turn: Turn<C, D>, run: Run, flow: Flow<C, D>): void {
1027
1125
  const onEnd = flow.onEnd ?? "end";
1028
1126
  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";
1127
+ const stay = onEnd === "stay" ? this.stayStep(run, flow) : undefined;
1128
+ if (stay) {
1129
+ // The steps after it ran once, on the way here; staying, it only answers.
1130
+ this.stayAt(run, stay);
1131
+ // One asker at a time. A run the lead's message went to holds the conversation and suspends the other asker;
1132
+ // any other run waits behind that asker and resumes when it is done.
1133
+ const others = turn.session.runs.filter((r) => r !== run && r.status === "asking");
1134
+ if (others.length && !(turn.what.kind === "message" && turn.floorRunId === run.id)) {
1135
+ run.status = "suspended";
1136
+ run.suspendedAt = turn.nowIso;
1137
+ return;
1138
+ }
1139
+ for (const other of others) {
1140
+ other.status = "suspended";
1141
+ other.suspendedAt = turn.nowIso;
1142
+ if (turn.talk?.run === other) turn.talk = undefined;
1143
+ }
1144
+ // A message nothing has answered yet gets its answer now. Reached in Ingest, the run stays asking: Decide judges its
1145
+ // branches as the asker's, and it answers when it moves.
1146
+ if (!turn.ingesting && turn.what.kind === "message" && turn.silenced === undefined && !turn.speakDone && turn.spokeBy.size === 0) {
1147
+ run.status = "running";
1148
+ }
1033
1149
  return;
1034
1150
  }
1035
1151
  if (onEnd === "reset" && last) {
@@ -1037,7 +1153,7 @@ export class Runner<C = unknown, D = unknown> {
1037
1153
  // 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
1154
  const key = this.stepKey(run, last);
1039
1155
  this.endRun(turn, run, "reset");
1040
- this.chain(turn, flow.id, key, run.input, run.hop + 1, true);
1156
+ this.chain(turn, run, flow.id, key, run.input, run.hop + 1, true);
1041
1157
  return;
1042
1158
  }
1043
1159
  this.endRun(turn, run, "end");
@@ -1060,9 +1176,20 @@ export class Runner<C = unknown, D = unknown> {
1060
1176
  turn.schedule.push({ key: waiting.key, at });
1061
1177
  }
1062
1178
 
1179
+ /** `resumeSuspended`, and on a message the resumed run's `if` branches are judged before it speaks, as the asker's are in Decide. */
1180
+ private resume(turn: Turn<C, D>): Run | undefined {
1181
+ const run = this.resumeSuspended(turn);
1182
+ if (run && turn.what.kind === "message") {
1183
+ const flow = this.flows.get(run.flowId);
1184
+ const step = flow && this.stepOf(flow, run.stepId);
1185
+ if (flow && step && isTalk(step)) this.fireBranch(turn, run, flow, step, null);
1186
+ }
1187
+ return run;
1188
+ }
1189
+
1063
1190
  /** 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;
1191
+ private resumeSuspended(turn: Turn<C, D>): Run | undefined {
1192
+ if (this.asker(turn)) return undefined;
1066
1193
  const suspendedAt = (r: Run): string => r.suspendedAt ?? r.startedAt;
1067
1194
  const next = turn.session.runs
1068
1195
  .filter((r) => r.status === "suspended")
@@ -1071,6 +1198,7 @@ export class Runner<C = unknown, D = unknown> {
1071
1198
  next.status = "asking";
1072
1199
  delete next.suspendedAt;
1073
1200
  }
1201
+ return next;
1074
1202
  }
1075
1203
 
1076
1204
  // ── Settle: the one applier after Speak (design §4.7) ─────────────────
@@ -1120,15 +1248,22 @@ export class Runner<C = unknown, D = unknown> {
1120
1248
  Object.assign(turn.session.data, spoken.data);
1121
1249
  this.outcome(turn, run, { kind: talkKind(step), status: "ok", key, llmCalls: spoken.llmCalls, stepId: step.id });
1122
1250
  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;
1251
+ const pending = step.collect?.length ? pendingFields(step, turn.session.data, run.asked) : [];
1252
+ for (const field of pending) run.asked[field] = (run.asked[field] ?? 0) + 1;
1253
+ if (run.staying) {
1254
+ // 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.
1255
+ const maxAsks = step.maxAsks ?? DEFAULT_MAX_ASKS;
1256
+ for (const field of pending) {
1257
+ if (run.asked[field] === maxAsks) this.outcome(turn, run, { kind: "collect", status: "skipped", key, code: "max-asks", detail: field });
1129
1258
  }
1130
- this.reportMaxAsks(turn, run, step);
1259
+ this.stayAt(run, step);
1260
+ return;
1261
+ }
1262
+ if (pending.length) {
1263
+ run.status = "asking";
1264
+ return;
1131
1265
  }
1266
+ this.reportMaxAsks(turn, run, step);
1132
1267
  run.status = "running";
1133
1268
  this.follow(turn, run, flow, step, step.then);
1134
1269
  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
@@ -45,6 +45,8 @@ export interface ScalarDef<T extends ScalarType = ScalarType> {
45
45
 
46
46
  /** One collectable field, authored once on the agent. */
47
47
  export interface FieldDef<T extends ScalarType = ScalarType> extends ScalarDef<T> {
48
+ /** The name a person reads, in an editor or next to a collected value. The model never sees it. */
49
+ label?: string;
48
50
  /** How the AI should ask for this field when a step collects it. */
49
51
  ask?: string;
50
52
  /**
@@ -185,6 +187,12 @@ export type TalkStep<C = unknown, D = unknown> = (
185
187
  ) & {
186
188
  /** Per-flow wording for a field; the field's own `ask` is the default. */
187
189
  ask?: Partial<Record<keyof D & string, string>>;
190
+ /**
191
+ * A fixed first question, sent word for word with no model call when the step
192
+ * first asks: every field it collects is still unknown and none was asked yet.
193
+ * Any later ask is the AI's own wording. Needs `collect`.
194
+ */
195
+ question?: Template;
188
196
  /** Times a field may be asked before it is skipped. Default 3. */
189
197
  maxAsks?: number;
190
198
  branches?: Branch<C, D>[];
@@ -253,9 +261,15 @@ export interface Flow<C = unknown, D = unknown> {
253
261
  anchor?: string;
254
262
  /** Re-checked whenever the run moves. Default: the trigger's `if`. */
255
263
  while?: Pred<C, D>;
264
+ /**
265
+ * The data this flow needs, as agent field slugs. Its talk steps' `collect`
266
+ * says which of it to ask, and in what order; a field no step asks is still
267
+ * noted whenever the lead gives it while the flow holds the conversation.
268
+ */
269
+ collect?: (keyof D & string)[];
256
270
  clearOnStart?: (keyof D & string)[];
257
271
  steps: Step<C, D>[];
258
- /** What the run does after its last step. Default `'end'`. */
272
+ /** What the run does after its last step. Default `'end'`. `'stay'`: the last talk step the run took answers every later message. */
259
273
  onEnd?: "end" | "stay" | "reset";
260
274
  instructions?: Instruction<C, D>[];
261
275
  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. */
@@ -75,6 +77,7 @@ export type StepOutcomeCode =
75
77
  | "silenced"
76
78
  // A step
77
79
  | "already-known"
80
+ | "asked-fixed"
78
81
  | "another-reply"
79
82
  | "already-sent"
80
83
  | "branch"
@@ -28,6 +28,7 @@ export const OUTCOME_MESSAGES = {
28
28
  silenced: "the host cannot speak right now",
29
29
 
30
30
  "already-known": "every field this step collects is known",
31
+ "asked-fixed": "the step's fixed question went out word for word",
31
32
  "another-reply": "another run already answered this turn",
32
33
  "already-sent": "this message was already sent once",
33
34
  branch: "a branch of this step fired",
@@ -76,7 +76,7 @@ export interface WireOptions {
76
76
 
77
77
  /**
78
78
  * The JSON schema a provider sees: an allow-list of JSON-schema keys, with
79
- * `ask`, `extract` and `optional` stripped. Closed (`additionalProperties:
79
+ * `label`, `ask`, `extract` and `optional` stripped. Closed (`additionalProperties:
80
80
  * false`); every property required unless a parameter says `optional`.
81
81
  */
82
82
  export function toWireSchema(defs: FieldDefs | ParamDefs, options: WireOptions = {}): StructuredSchema {
@@ -102,8 +102,8 @@ function wireProperty(def: FieldDef | ParamDef, nullable: boolean): StructuredSc
102
102
 
103
103
  /**
104
104
  * Merge field rows authored per flow into the agent's one field set. Two
105
- * rows for one slug must agree on type and enum; the first non-empty `ask`
106
- * and `description` win.
105
+ * rows for one slug must agree on type and enum; the first non-empty `label`,
106
+ * `ask` and `description` win.
107
107
  */
108
108
  export function buildSchema(groups: Array<{ id: string; fields: FieldDefs }>): FieldDefs {
109
109
  const merged: FieldDefs = {};
@@ -131,6 +131,7 @@ export function buildSchema(groups: Array<{ id: string; fields: FieldDefs }>): F
131
131
  merged[slug] = {
132
132
  ...seen,
133
133
  enum: seen.enum ?? def.enum,
134
+ label: seen.label ?? def.label,
134
135
  ask: seen.ask ?? def.ask,
135
136
  description: seen.description ?? def.description,
136
137
  extract: seen.extract ?? def.extract,