@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.
- package/dist/cjs/core/FlowSpec.d.ts +2 -0
- package/dist/cjs/core/FlowSpec.d.ts.map +1 -1
- package/dist/cjs/core/FlowSpec.js +26 -3
- 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 +17 -1
- package/dist/cjs/core/Runner.d.ts.map +1 -1
- package/dist/cjs/core/Runner.js +178 -35
- 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 +15 -1
- package/dist/cjs/types/flow.d.ts.map +1 -1
- package/dist/cjs/types/session.d.ts +3 -1
- package/dist/cjs/types/session.d.ts.map +1 -1
- package/dist/cjs/utils/outcomes.d.ts +1 -0
- package/dist/cjs/utils/outcomes.d.ts.map +1 -1
- package/dist/cjs/utils/outcomes.js +1 -0
- package/dist/cjs/utils/outcomes.js.map +1 -1
- package/dist/cjs/utils/schema.d.ts +3 -3
- package/dist/cjs/utils/schema.d.ts.map +1 -1
- package/dist/cjs/utils/schema.js +4 -3
- package/dist/cjs/utils/schema.js.map +1 -1
- package/dist/core/FlowSpec.d.ts +2 -0
- package/dist/core/FlowSpec.d.ts.map +1 -1
- package/dist/core/FlowSpec.js +27 -4
- 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 +17 -1
- package/dist/core/Runner.d.ts.map +1 -1
- package/dist/core/Runner.js +178 -35
- 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 +15 -1
- package/dist/types/flow.d.ts.map +1 -1
- package/dist/types/session.d.ts +3 -1
- package/dist/types/session.d.ts.map +1 -1
- package/dist/utils/outcomes.d.ts +1 -0
- package/dist/utils/outcomes.d.ts.map +1 -1
- package/dist/utils/outcomes.js +1 -0
- package/dist/utils/outcomes.js.map +1 -1
- package/dist/utils/schema.d.ts +3 -3
- package/dist/utils/schema.d.ts.map +1 -1
- package/dist/utils/schema.js +4 -3
- package/dist/utils/schema.js.map +1 -1
- package/docs/concepts/collection.md +40 -5
- package/docs/concepts/pipeline.md +6 -5
- 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/fields.md +5 -3
- package/docs/reference/flow-spec.md +6 -3
- package/docs/reference/flow.md +8 -3
- package/docs/reference/outcomes.md +2 -1
- package/docs/reference/session.md +2 -0
- package/docs/reference/step.md +7 -4
- package/docs/rfc/v4-one-flow.md +2 -0
- package/examples/05-branches.ts +1 -1
- package/package.json +1 -1
- package/src/core/FlowSpec.ts +33 -5
- package/src/core/Migrate.ts +2 -1
- package/src/core/Runner.ts +167 -32
- package/src/core/Speak.ts +3 -1
- package/src/types/flow.ts +15 -1
- package/src/types/session.ts +3 -0
- package/src/utils/outcomes.ts +1 -0
- package/src/utils/schema.ts +4 -3
package/src/core/FlowSpec.ts
CHANGED
|
@@ -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 (
|
|
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
|
}
|
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
|
@@ -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
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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 (!
|
|
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
|
|
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.
|
|
731
|
+
this.resume(turn);
|
|
704
732
|
turn.queue = [...turn.session.runs];
|
|
705
733
|
await this.drain(turn);
|
|
706
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1009
|
-
|
|
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
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
run
|
|
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>):
|
|
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
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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.
|
|
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[];
|
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. */
|
|
@@ -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"
|
package/src/utils/outcomes.ts
CHANGED
|
@@ -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",
|
package/src/utils/schema.ts
CHANGED
|
@@ -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 `
|
|
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,
|