@falai/agent 4.0.0-alpha.6 → 4.0.0-alpha.8

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.
@@ -251,6 +251,7 @@ interface LooseStep {
251
251
  ask?: Partial<Record<string, string>>;
252
252
  branches?: LooseBranch[];
253
253
  instructions?: LooseInstruction[];
254
+ say?: Template;
254
255
  do?: string;
255
256
  with?: Record<string, unknown>;
256
257
  wait?: Duration | { event: string; upTo?: Duration };
@@ -271,6 +272,12 @@ const BUILT_IN_CONDITIONS = ["equals", "known", "silenced"];
271
272
 
272
273
  const DURATION_HINT = 'Write a number and a unit: "30s", "5m", "24h" or "3d".';
273
274
 
275
+ /** The four keys one of which makes an `on[]` entry a trigger. */
276
+ const TRIGGER_KINDS = ["message", "mention", "silence", "event"] as const;
277
+
278
+ /** The six keys one of which makes a step do something. */
279
+ const STEP_DOES = ["prompt", "collect", "say", "do", "wait", "if"] as const;
280
+
274
281
  /**
275
282
  * Check a flow, typed or as a spec, against the agent's registries. Throws
276
283
  * `FlowConfigurationError` on the first problem that would break at runtime;
@@ -424,6 +431,18 @@ export function validateFlow<C = unknown, D = LooseData>(
424
431
 
425
432
  flow.on?.forEach((trigger, i) => {
426
433
  const at = `${flowAt}, trigger #${i + 1}`;
434
+ // A trigger that names no kind can never fire, and nothing downstream says
435
+ // so: the Runner simply never finds it eligible and the flow looks broken
436
+ // for some other reason. `{ kind: 'message', when: [...] }` — the v3 shape —
437
+ // lands here, and so does a typo in the one key that matters.
438
+ if (!TRIGGER_KINDS.some((key) => trigger[key] !== undefined)) {
439
+ throw problem(
440
+ at,
441
+ "names no trigger kind",
442
+ `A trigger is one of ${TRIGGER_KINDS.map((key) => `\`${key}\``).join(", ")}. ` +
443
+ "A flow the host starts itself has no `on` at all.",
444
+ );
445
+ }
427
446
  if (trigger.event !== undefined && !own(events, trigger.event)) {
428
447
  throw problem(at, `unknown event "${trigger.event}"`, "Register it in events or fix the name.");
429
448
  }
@@ -450,6 +469,17 @@ export function validateFlow<C = unknown, D = LooseData>(
450
469
 
451
470
  flow.steps.forEach((step, i) => {
452
471
  const at = `${flowAt}, step "${step.id}"`;
472
+ // Same reasoning as the trigger above: a step that does none of the five
473
+ // things is a step the run walks straight past, silently. `kind` alone is
474
+ // not enough — the spec form drops it and keeps the body, so what counts
475
+ // is whether the body says what to do.
476
+ if (!STEP_DOES.some((key) => step[key] !== undefined)) {
477
+ throw problem(
478
+ at,
479
+ "does nothing",
480
+ "A step talks (`prompt` / `collect`), says (`say`), acts (`do`), waits (`wait`) or forks (`if`).",
481
+ );
482
+ }
453
483
  const thenTo = edge(i, step.then, at, "then");
454
484
  edge(i, step.else, at, "else");
455
485
 
@@ -529,7 +559,12 @@ function describe(value: unknown): string {
529
559
  }
530
560
 
531
561
  function describeDef(def: ParamDef): string {
532
- return def.type === "array" ? `a list of ${def.items.type}` : `a ${def.type}`;
562
+ return def.type === "array" ? `a list of ${def.items.type}s` : `${article(def.type)} ${def.type}`;
563
+ }
564
+
565
+ /** "an integer", "a string". A vowel test rather than one hard-coded type, so a new type reads right for free. */
566
+ function article(type: string): string {
567
+ return /^[aeiou]/.test(type) ? "an" : "a";
533
568
  }
534
569
 
535
570
  // ── 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 } }`. */