@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.
- package/dist/cjs/core/FlowSpec.js +1 -1
- package/dist/cjs/core/FlowSpec.js.map +1 -1
- package/dist/cjs/core/Migrate.d.ts.map +1 -1
- package/dist/cjs/core/Migrate.js +3 -1
- package/dist/cjs/core/Migrate.js.map +1 -1
- package/dist/cjs/core/Runner.d.ts +11 -1
- package/dist/cjs/core/Runner.d.ts.map +1 -1
- package/dist/cjs/core/Runner.js +115 -23
- package/dist/cjs/core/Runner.js.map +1 -1
- package/dist/cjs/core/Speak.d.ts.map +1 -1
- package/dist/cjs/core/Speak.js +3 -1
- package/dist/cjs/core/Speak.js.map +1 -1
- package/dist/cjs/types/flow.d.ts +1 -1
- package/dist/cjs/types/flow.d.ts.map +1 -1
- package/dist/cjs/types/session.d.ts +2 -0
- package/dist/cjs/types/session.d.ts.map +1 -1
- package/dist/core/FlowSpec.js +1 -1
- package/dist/core/FlowSpec.js.map +1 -1
- package/dist/core/Migrate.d.ts.map +1 -1
- package/dist/core/Migrate.js +3 -1
- package/dist/core/Migrate.js.map +1 -1
- package/dist/core/Runner.d.ts +11 -1
- package/dist/core/Runner.d.ts.map +1 -1
- package/dist/core/Runner.js +115 -23
- package/dist/core/Runner.js.map +1 -1
- package/dist/core/Speak.d.ts.map +1 -1
- package/dist/core/Speak.js +3 -1
- package/dist/core/Speak.js.map +1 -1
- package/dist/types/flow.d.ts +1 -1
- package/dist/types/flow.d.ts.map +1 -1
- package/dist/types/session.d.ts +2 -0
- package/dist/types/session.d.ts.map +1 -1
- package/docs/concepts/pipeline.md +3 -3
- package/docs/concepts/runs-and-waits.md +1 -1
- package/docs/guides/branching.md +3 -1
- package/docs/guides/flow-control.md +3 -1
- package/docs/migration/v3-to-v4.md +1 -1
- package/docs/reference/branches.md +1 -1
- package/docs/reference/flow.md +1 -1
- package/docs/reference/session.md +2 -0
- package/docs/reference/step.md +2 -2
- package/examples/05-branches.ts +1 -1
- package/package.json +1 -1
- package/src/core/FlowSpec.ts +1 -1
- package/src/core/Migrate.ts +2 -1
- package/src/core/Runner.ts +111 -22
- package/src/core/Speak.ts +3 -1
- package/src/types/flow.ts +1 -1
- 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. |
|
package/docs/reference/step.md
CHANGED
|
@@ -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'`
|
|
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
|
|
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
|
|
package/examples/05-branches.ts
CHANGED
|
@@ -54,7 +54,7 @@ const agent = f.agent({
|
|
|
54
54
|
then: "dados",
|
|
55
55
|
},
|
|
56
56
|
],
|
|
57
|
-
// After the last step the run
|
|
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.
|
|
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",
|
package/src/core/FlowSpec.ts
CHANGED
|
@@ -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
|
}
|
package/src/core/Migrate.ts
CHANGED
|
@@ -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
|
|
package/src/core/Runner.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
1009
|
-
|
|
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
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
run
|
|
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>):
|
|
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
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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.
|
|
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[];
|
package/src/types/session.ts
CHANGED
|
@@ -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. */
|