@falai/agent 4.0.0-alpha.3 → 4.0.0-alpha.5

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 (45) hide show
  1. package/dist/cjs/core/FlowSpec.d.ts.map +1 -1
  2. package/dist/cjs/core/FlowSpec.js +12 -0
  3. package/dist/cjs/core/FlowSpec.js.map +1 -1
  4. package/dist/cjs/core/Prompt.d.ts.map +1 -1
  5. package/dist/cjs/core/Prompt.js +7 -1
  6. package/dist/cjs/core/Prompt.js.map +1 -1
  7. package/dist/cjs/core/Understand.d.ts.map +1 -1
  8. package/dist/cjs/core/Understand.js +13 -7
  9. package/dist/cjs/core/Understand.js.map +1 -1
  10. package/dist/cjs/utils/phrases.d.ts +25 -0
  11. package/dist/cjs/utils/phrases.d.ts.map +1 -0
  12. package/dist/cjs/utils/phrases.js +38 -0
  13. package/dist/cjs/utils/phrases.js.map +1 -0
  14. package/dist/cjs/utils/template.d.ts +5 -0
  15. package/dist/cjs/utils/template.d.ts.map +1 -1
  16. package/dist/cjs/utils/template.js +28 -2
  17. package/dist/cjs/utils/template.js.map +1 -1
  18. package/dist/core/FlowSpec.d.ts.map +1 -1
  19. package/dist/core/FlowSpec.js +12 -0
  20. package/dist/core/FlowSpec.js.map +1 -1
  21. package/dist/core/Prompt.d.ts.map +1 -1
  22. package/dist/core/Prompt.js +7 -1
  23. package/dist/core/Prompt.js.map +1 -1
  24. package/dist/core/Understand.d.ts.map +1 -1
  25. package/dist/core/Understand.js +13 -7
  26. package/dist/core/Understand.js.map +1 -1
  27. package/dist/utils/phrases.d.ts +25 -0
  28. package/dist/utils/phrases.d.ts.map +1 -0
  29. package/dist/utils/phrases.js +35 -0
  30. package/dist/utils/phrases.js.map +1 -0
  31. package/dist/utils/template.d.ts +5 -0
  32. package/dist/utils/template.d.ts.map +1 -1
  33. package/dist/utils/template.js +28 -2
  34. package/dist/utils/template.js.map +1 -1
  35. package/docs/guides/actions-and-events.md +1 -1
  36. package/docs/migration/v3-to-v4.md +8 -4
  37. package/docs/reference/actions-events-conditions.md +1 -1
  38. package/docs/reference/trigger.md +20 -0
  39. package/docs/start/04-add-tools.md +1 -1
  40. package/package.json +3 -2
  41. package/src/core/FlowSpec.ts +18 -0
  42. package/src/core/Prompt.ts +7 -1
  43. package/src/core/Understand.ts +9 -5
  44. package/src/utils/phrases.ts +40 -0
  45. package/src/utils/template.ts +27 -2
@@ -43,6 +43,7 @@ import type {
43
43
  } from "../types/flow.js";
44
44
  import type { StructuredSchema } from "../types/schema.js";
45
45
  import { isDuration } from "../utils/duration.js";
46
+ import { splitPhrases } from "../utils/phrases.js";
46
47
  import { toWireSchema } from "../utils/schema.js";
47
48
 
48
49
  // ── The JSON form ───────────────────────────────────────────────────────
@@ -233,6 +234,8 @@ interface LooseInstruction {
233
234
  }
234
235
  interface LooseTrigger {
235
236
  repeat?: Repeat;
237
+ message?: string[];
238
+ mention?: string[];
236
239
  event?: string;
237
240
  silence?: Duration;
238
241
  after?: Duration;
@@ -428,6 +431,21 @@ export function validateFlow<C = unknown, D = LooseData>(
428
431
  duration(trigger.after, at, "after");
429
432
  if (typeof trigger.repeat === "object") duration(trigger.repeat.cooldown, at, "repeat.cooldown");
430
433
  pred(trigger.if, at, "if");
434
+ // Phrases opening with `!` rule the trigger out; a list of nothing but
435
+ // those can never fire, so the flow is dead and nothing would say so.
436
+ // `message: []` is the deliberate catch-all and stays legal.
437
+ for (const key of ["message", "mention"] as const) {
438
+ const phrases: string[] | undefined = trigger[key];
439
+ if (!phrases?.length) continue;
440
+ if (splitPhrases(phrases).counts.length > 0) continue;
441
+ throw problem(
442
+ at,
443
+ `every ${key} phrase starts with "!", so nothing can ever match it`,
444
+ `A "!" phrase rules the trigger out. Add at least one plain phrase saying when it should fire${
445
+ key === "message" ? ", or use an empty list for a catch-all" : ""
446
+ }.`,
447
+ );
448
+ }
431
449
  });
432
450
 
433
451
  flow.steps.forEach((step, i) => {
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { AgentOptions } from "../types/agent.js";
11
11
  import type { FieldDef, Instruction } from "../types/flow.js";
12
+ import { splitPhrases } from "../utils/phrases.js";
12
13
  import { isKnown } from "../utils/schema.js";
13
14
  import { render, type TemplateScope } from "../utils/template.js";
14
15
 
@@ -106,7 +107,12 @@ export function instructionsSection<C, D>(groups: InstructionGroup<C, D>[], scop
106
107
  const text = render(item.prompt, scope).trim();
107
108
  if (!text) continue;
108
109
  const when = item.when === undefined ? [] : Array.isArray(item.when) ? item.when : [item.when];
109
- const condition = when.length ? ` (apply only when: ${when.join(" OR ")})` : "";
110
+ const { counts, excludes } = splitPhrases(when);
111
+ const clauses = [
112
+ ...(counts.length ? [`apply only when: ${counts.join(" OR ")}`] : []),
113
+ ...(excludes.length ? [`never when: ${excludes.join(" OR ")}`] : []),
114
+ ];
115
+ const condition = clauses.length ? ` (${clauses.join("; ")})` : "";
110
116
  lines.push(`- [${item.kind ?? "should"}] ${group.caption} ${text}${condition}`);
111
117
  }
112
118
  }
@@ -23,6 +23,7 @@ import type { FieldDef, FieldDefs, Flow, ParamDef, ParamDefs } from "../types/fl
23
23
  import type { StructuredSchema } from "../types/schema.js";
24
24
  import { extractEmbeddedJSONObject, isRecord } from "../utils/json.js";
25
25
  import { logger } from "../utils/logger.js";
26
+ import { splitPhrases } from "../utils/phrases.js";
26
27
  import { coerceField, isKnown, pendingFields, toWireSchema } from "../utils/schema.js";
27
28
  import { render, type TemplateScope } from "../utils/template.js";
28
29
  import { readUsage } from "../utils/usage.js";
@@ -245,8 +246,9 @@ function flowsSection<C, D>(candidates: Flow<C, D>[], aliases: Aliases, t: (text
245
246
  ];
246
247
  candidates.forEach((flow, i) => {
247
248
  lines.push(`${i + 1}. ${aliases.of(flow.id, "f")} — ${flow.name}${flow.description ? `: ${t(flow.description)}` : ""}`);
248
- const phrases = triggerPhrases(flow, "message");
249
- if (phrases.length) lines.push(` The customer: ${phrases.map(t).join("; ")}`);
249
+ const { counts, excludes } = splitPhrases(triggerPhrases(flow, "message"));
250
+ if (counts.length) lines.push(` The customer: ${counts.map(t).join("; ")}`);
251
+ if (excludes.length) lines.push(` Score 0 when: ${excludes.map(t).join("; ")}`);
250
252
  });
251
253
  lines.push(
252
254
  "",
@@ -265,12 +267,14 @@ function mentionsSection<C, D>(flows: Flow<C, D>[], aliases: Aliases, t: (text:
265
267
  const lines = [
266
268
  "## Things the customer may mention",
267
269
  "For each item, answer true when the customer's message clearly brings it up, false otherwise. " +
268
- "Be conservative: true needs clear, explicit evidence in the message. The phrases under an item are alternatives; one match is enough.",
270
+ "Be conservative: true needs clear, explicit evidence in the message. The phrases under an item are alternatives; one match is enough. " +
271
+ "A 'Does not count when' line overrides a match: if one of those fits, answer false.",
269
272
  ];
270
273
  for (const flow of flows) {
271
274
  lines.push(`- ${aliases.of(flow.id, "f")} — ${flow.name}${flow.description ? `: ${t(flow.description)}` : ""}`);
272
- const phrases = triggerPhrases(flow, "mention");
273
- if (phrases.length) lines.push(` Counts when: ${phrases.map(t).join("; ")}`);
275
+ const { counts, excludes } = splitPhrases(triggerPhrases(flow, "mention"));
276
+ if (counts.length) lines.push(` Counts when: ${counts.map(t).join("; ")}`);
277
+ if (excludes.length) lines.push(` Does not count when: ${excludes.map(t).join("; ")}`);
274
278
  const defs = extractDefs(flow);
275
279
  if (defs) {
276
280
  lines.push(" When true, also extract:");
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Splitting an AI-judged phrase list into what counts and what rules it out.
3
+ *
4
+ * A trigger's phrases are alternatives: one match is enough. That makes the
5
+ * list useless for saying "but not this" — and "but not this" is what keeps a
6
+ * classifier honest. A customer answering "pode sim" to an offer of a meeting
7
+ * is agreeing to the meeting, not asking for a human, and without a way to say
8
+ * so every cheerful yes reads as a handoff request.
9
+ *
10
+ * So a phrase that opens with `!` is an exclusion: it does not make the trigger
11
+ * fire, it stops it. Exclusions win over matches, and the model is told both
12
+ * lists separately.
13
+ */
14
+
15
+ export interface Phrases {
16
+ /** Any one of these makes it true. */
17
+ counts: string[];
18
+ /** Any one of these makes it false, whatever matched. */
19
+ excludes: string[];
20
+ }
21
+
22
+ /**
23
+ * Split a phrase list on the leading `!`. Blank entries and a bare `"!"` are
24
+ * dropped: they would render as an empty bullet and mean nothing to the model.
25
+ */
26
+ export function splitPhrases(phrases: readonly string[]): Phrases {
27
+ const counts: string[] = [];
28
+ const excludes: string[] = [];
29
+ for (const phrase of phrases) {
30
+ const trimmed = phrase.trim();
31
+ if (!trimmed) continue;
32
+ if (trimmed.startsWith("!")) {
33
+ const body = trimmed.slice(1).trim();
34
+ if (body) excludes.push(body);
35
+ continue;
36
+ }
37
+ counts.push(trimmed);
38
+ }
39
+ return { counts, excludes };
40
+ }
@@ -5,6 +5,11 @@
5
5
  * per-turn context) and `input` (the run's trigger payload). A path that
6
6
  * resolves to nothing keeps its placeholder, so a typo stays visible instead
7
7
  * of vanishing into an empty string.
8
+ *
9
+ * A path that resolves to an EMPTY string is a different case: the host knows
10
+ * the field and knows it is blank. Then the placeholder goes and `tidy` closes
11
+ * the gap it left, so "Ola {{context.lead.name}}, tudo bem?" sends
12
+ * "Ola, tudo bem?" and never "Ola , tudo bem?".
8
13
  */
9
14
 
10
15
  export interface TemplateScope {
@@ -16,10 +21,30 @@ export interface TemplateScope {
16
21
  const PLACEHOLDER = /\{\{\s*([^}\s]+)\s*\}\}/g;
17
22
 
18
23
  export function render(template: string, scope: TemplateScope): string {
19
- return template.replace(PLACEHOLDER, (match, path: string) => {
24
+ let emptied = false;
25
+ const out = template.replace(PLACEHOLDER, (match, path: string) => {
20
26
  const value = lookup(scope, path.split("."));
21
- return value === undefined || value === null ? match : stringify(value);
27
+ if (value === undefined || value === null) return match;
28
+ const text = stringify(value);
29
+ if (text === "") emptied = true;
30
+ return text;
22
31
  });
32
+ return emptied ? tidy(out) : out;
33
+ }
34
+
35
+ /**
36
+ * Close the hole an empty value leaves: a doubled space, a space before
37
+ * punctuation, a space at the end of a line.
38
+ *
39
+ * Deliberately narrow. It only runs on a string where something substituted to
40
+ * "", and it only collapses a run of spaces that follows a visible character,
41
+ * so indentation in a markdown list survives.
42
+ */
43
+ function tidy(text: string): string {
44
+ return text
45
+ .replace(/(?<=\S)[ \t]{2,}/g, " ")
46
+ .replace(/ +([,.;:!?\u2026])/g, "$1")
47
+ .replace(/[ \t]+$/gm, "");
23
48
  }
24
49
 
25
50
  /** `render` over every string inside a value, recursively. Non-strings pass through. */