@falai/agent 4.0.0-alpha.7 → 4.0.0-alpha.9

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.
@@ -410,11 +410,8 @@ export function validateFlow<C = unknown, D = LooseData>(
410
410
  continue;
411
411
  }
412
412
  if (!matchesParam(def, value)) {
413
- throw problem(
414
- at,
415
- `parameter "${param}" of action "${name}" must be ${describeDef(def)}, got ${describe(value)}`,
416
- "Values are not coerced; write the right type.",
417
- );
413
+ const { expected, got, fix } = mismatch(def, value);
414
+ throw problem(at, `parameter "${param}" of action "${name}" must be ${expected}, got ${got}`, fix);
418
415
  }
419
416
  }
420
417
  for (const param of Object.keys(given)) {
@@ -554,12 +551,33 @@ function matches(def: ScalarDef, value: unknown): boolean {
554
551
  return typeof value !== "boolean" && def.enum.includes(value);
555
552
  }
556
553
 
554
+ /**
555
+ * What a rejected value should have been and what it was. A value of the right type that is
556
+ * not a listed one names the listed values: "must be a string, got string" would say nothing.
557
+ */
558
+ function mismatch(def: ParamDef, value: unknown): { expected: string; got: string; fix: string } {
559
+ const scalar = def.type === "array" ? def.items : def;
560
+ const { enum: allowed, ...typeOnly } = scalar;
561
+ const items = def.type === "array" && Array.isArray(value) ? value : [value];
562
+ const off = allowed ? items.findIndex((item) => matches(typeOnly, item) && !matches(scalar, item)) : -1;
563
+ if (!allowed || off === -1) {
564
+ return { expected: describeDef(def), got: describe(value), fix: "Values are not coerced; write the right type." };
565
+ }
566
+ const list = (v: unknown) => JSON.stringify(v);
567
+ return { expected: `one of ${allowed.map(list).join(", ")}`, got: list(items[off]), fix: "Use one of the listed values." };
568
+ }
569
+
557
570
  function describe(value: unknown): string {
558
571
  return Array.isArray(value) ? "list" : value === null ? "null" : typeof value;
559
572
  }
560
573
 
561
574
  function describeDef(def: ParamDef): string {
562
- return def.type === "array" ? `a list of ${def.items.type}` : `a ${def.type}`;
575
+ return def.type === "array" ? `a list of ${def.items.type}s` : `${article(def.type)} ${def.type}`;
576
+ }
577
+
578
+ /** "an integer", "a string". A vowel test rather than one hard-coded type, so a new type reads right for free. */
579
+ function article(type: string): string {
580
+ return /^[aeiou]/.test(type) ? "an" : "a";
563
581
  }
564
582
 
565
583
  // ── flowSpecSchema ──────────────────────────────────────────────────────
@@ -508,8 +508,7 @@ export class Runner<C = unknown, D = unknown> {
508
508
  const floorRun = this.floorRun(turn);
509
509
  const floorFlow = floorRun && this.flows.get(floorRun.flowId);
510
510
  const eligible = this.eligibleMessageFlows(turn);
511
- // Exactly one eligible message flow and no floor: it starts without scoring (S9).
512
- const messageFlows = !floorRun && eligible.length === 1 ? [] : eligible;
511
+ const messageFlows = !floorRun && this.startsUnscored(turn, eligible) ? [] : eligible;
513
512
  const mentionFlows = this.mentionFlows()
514
513
  .filter(({ flow, trigger }) => trigger.mention.length > 0 && this.repeatAllows(turn, flow, trigger, turn.triggerKey))
515
514
  .map(({ flow }) => flow);
@@ -562,6 +561,14 @@ export class Runner<C = unknown, D = unknown> {
562
561
  return this.messageFlows(turn, (list) => list.length > 0);
563
562
  }
564
563
 
564
+ /**
565
+ * The lone eligible flow starts without a score only when a low score would have nowhere
566
+ * else to send the message: no catch-all passes and the idle speaker is silent (S9).
567
+ */
568
+ private startsUnscored(turn: Turn<C, D>, eligible: Flow<C, D>[]): boolean {
569
+ return eligible.length === 1 && this.options.idle === "silent" && this.messageFlows(turn, (list) => list.length === 0).length === 0;
570
+ }
571
+
565
572
  private messageFlows(turn: Turn<C, D>, accept: (list: string[]) => boolean): Flow<C, D>[] {
566
573
  const out: Flow<C, D>[] = [];
567
574
  for (const flow of this.flows.values()) {
@@ -605,7 +612,7 @@ export class Runner<C = unknown, D = unknown> {
605
612
  const current = scores[asker.flowId] ?? 0;
606
613
  const top = best(eligible.filter((f) => f.id !== asker.flowId));
607
614
  if (top && top.score >= current + ROUTE_STICKY && top.score >= ROUTE_MIN) route = top.flow;
608
- } else if (eligible.length === 1) {
615
+ } else if (this.startsUnscored(turn, eligible)) {
609
616
  route = eligible[0];
610
617
  } else {
611
618
  const top = best(eligible);
@@ -44,12 +44,9 @@ export class Understand<C = unknown, D = unknown> {
44
44
  const candidates = candidateFlows(req);
45
45
  const onlyRouting =
46
46
  req.mentionFlows.length === 0 && req.branches.length === 0 && Object.keys(req.fields).length === 0;
47
- if (onlyRouting && candidates.length <= 1) {
48
- // One eligible flow and nobody on the floor: it starts, no scoring.
49
- // Otherwise there is nothing to compare or extract.
50
- const only = candidates[0];
51
- return only && !req.floor ? { ...empty(0), flows: { [only.id]: 100 } } : empty(0);
52
- }
47
+ // Nothing to compare or extract: no candidates, or only the floor's own flow. A lone
48
+ // candidate with nobody on the floor is here because the runner wants it scored.
49
+ if (onlyRouting && candidates.length <= (req.floor ? 1 : 0)) return empty(0);
53
50
 
54
51
  const aliases = new Aliases();
55
52
  const jsonSchema = buildEnvelope(req, candidates, aliases);
@@ -87,7 +87,11 @@ export type TurnBase<C = unknown, D = unknown> = ContextField<C> & {
87
87
  sessionId: string;
88
88
  /** Absent on a first turn. A wake never creates a session. */
89
89
  session?: Session<D>;
90
- /** Pass on every input kind, wakes included. */
90
+ /**
91
+ * The conversation BEFORE this input. Pass it on every input kind, wakes included.
92
+ * Leave out the message this turn carries: both calls quote it on their own, so a
93
+ * history that ends with it makes the model read it twice.
94
+ */
91
95
  history?: History;
92
96
  silenced?: Silenced;
93
97
  /** Host anchors this session belongs to, e.g. `{ lead: { key: 'lead:456', lastInboundAt } }`. */