@falai/agent 4.0.0-alpha.1 → 4.0.0-alpha.11

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 (118) hide show
  1. package/dist/cjs/core/Agent.d.ts +8 -1
  2. package/dist/cjs/core/Agent.d.ts.map +1 -1
  3. package/dist/cjs/core/Agent.js +9 -0
  4. package/dist/cjs/core/Agent.js.map +1 -1
  5. package/dist/cjs/core/FlowSpec.d.ts.map +1 -1
  6. package/dist/cjs/core/FlowSpec.js +53 -2
  7. package/dist/cjs/core/FlowSpec.js.map +1 -1
  8. package/dist/cjs/core/Prompt.d.ts.map +1 -1
  9. package/dist/cjs/core/Prompt.js +7 -1
  10. package/dist/cjs/core/Prompt.js.map +1 -1
  11. package/dist/cjs/core/Runner.d.ts +14 -3
  12. package/dist/cjs/core/Runner.d.ts.map +1 -1
  13. package/dist/cjs/core/Runner.js +52 -23
  14. package/dist/cjs/core/Runner.js.map +1 -1
  15. package/dist/cjs/core/Speak.d.ts.map +1 -1
  16. package/dist/cjs/core/Speak.js +11 -2
  17. package/dist/cjs/core/Speak.js.map +1 -1
  18. package/dist/cjs/core/Understand.d.ts.map +1 -1
  19. package/dist/cjs/core/Understand.js +17 -13
  20. package/dist/cjs/core/Understand.js.map +1 -1
  21. package/dist/cjs/index.d.ts +1 -1
  22. package/dist/cjs/index.d.ts.map +1 -1
  23. package/dist/cjs/index.js.map +1 -1
  24. package/dist/cjs/providers/ProviderAdapter.d.ts +10 -5
  25. package/dist/cjs/providers/ProviderAdapter.d.ts.map +1 -1
  26. package/dist/cjs/providers/ProviderAdapter.js.map +1 -1
  27. package/dist/cjs/providers/ZaiProvider.d.ts +6 -4
  28. package/dist/cjs/providers/ZaiProvider.d.ts.map +1 -1
  29. package/dist/cjs/providers/ZaiProvider.js +6 -4
  30. package/dist/cjs/providers/ZaiProvider.js.map +1 -1
  31. package/dist/cjs/types/agent.d.ts +12 -2
  32. package/dist/cjs/types/agent.d.ts.map +1 -1
  33. package/dist/cjs/types/index.d.ts +1 -1
  34. package/dist/cjs/types/index.d.ts.map +1 -1
  35. package/dist/cjs/types/index.js.map +1 -1
  36. package/dist/cjs/utils/phrases.d.ts +25 -0
  37. package/dist/cjs/utils/phrases.d.ts.map +1 -0
  38. package/dist/cjs/utils/phrases.js +38 -0
  39. package/dist/cjs/utils/phrases.js.map +1 -0
  40. package/dist/cjs/utils/template.d.ts +8 -0
  41. package/dist/cjs/utils/template.d.ts.map +1 -1
  42. package/dist/cjs/utils/template.js +40 -3
  43. package/dist/cjs/utils/template.js.map +1 -1
  44. package/dist/core/Agent.d.ts +8 -1
  45. package/dist/core/Agent.d.ts.map +1 -1
  46. package/dist/core/Agent.js +9 -0
  47. package/dist/core/Agent.js.map +1 -1
  48. package/dist/core/FlowSpec.d.ts.map +1 -1
  49. package/dist/core/FlowSpec.js +53 -2
  50. package/dist/core/FlowSpec.js.map +1 -1
  51. package/dist/core/Prompt.d.ts.map +1 -1
  52. package/dist/core/Prompt.js +7 -1
  53. package/dist/core/Prompt.js.map +1 -1
  54. package/dist/core/Runner.d.ts +14 -3
  55. package/dist/core/Runner.d.ts.map +1 -1
  56. package/dist/core/Runner.js +52 -23
  57. package/dist/core/Runner.js.map +1 -1
  58. package/dist/core/Speak.d.ts.map +1 -1
  59. package/dist/core/Speak.js +11 -2
  60. package/dist/core/Speak.js.map +1 -1
  61. package/dist/core/Understand.d.ts.map +1 -1
  62. package/dist/core/Understand.js +17 -13
  63. package/dist/core/Understand.js.map +1 -1
  64. package/dist/index.d.ts +1 -1
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js.map +1 -1
  67. package/dist/providers/ProviderAdapter.d.ts +10 -5
  68. package/dist/providers/ProviderAdapter.d.ts.map +1 -1
  69. package/dist/providers/ProviderAdapter.js.map +1 -1
  70. package/dist/providers/ZaiProvider.d.ts +6 -4
  71. package/dist/providers/ZaiProvider.d.ts.map +1 -1
  72. package/dist/providers/ZaiProvider.js +6 -4
  73. package/dist/providers/ZaiProvider.js.map +1 -1
  74. package/dist/types/agent.d.ts +12 -2
  75. package/dist/types/agent.d.ts.map +1 -1
  76. package/dist/types/index.d.ts +1 -1
  77. package/dist/types/index.d.ts.map +1 -1
  78. package/dist/types/index.js.map +1 -1
  79. package/dist/utils/phrases.d.ts +25 -0
  80. package/dist/utils/phrases.d.ts.map +1 -0
  81. package/dist/utils/phrases.js +35 -0
  82. package/dist/utils/phrases.js.map +1 -0
  83. package/dist/utils/template.d.ts +8 -0
  84. package/dist/utils/template.d.ts.map +1 -1
  85. package/dist/utils/template.js +40 -3
  86. package/dist/utils/template.js.map +1 -1
  87. package/docs/concepts/architecture.md +2 -2
  88. package/docs/concepts/pipeline.md +4 -4
  89. package/docs/guides/actions-and-events.md +1 -1
  90. package/docs/guides/conditions.md +1 -1
  91. package/docs/guides/error-handling.md +2 -0
  92. package/docs/guides/persistence.md +1 -1
  93. package/docs/guides/testing.md +1 -1
  94. package/docs/guides/triggers.md +5 -5
  95. package/docs/migration/v3-to-v4.md +12 -7
  96. package/docs/reference/actions-events-conditions.md +2 -2
  97. package/docs/reference/agent.md +8 -4
  98. package/docs/reference/flow-spec.md +1 -1
  99. package/docs/reference/providers.md +2 -2
  100. package/docs/reference/trigger.md +24 -4
  101. package/docs/rfc/v4-one-flow.md +5 -5
  102. package/docs/start/04-add-tools.md +1 -1
  103. package/docs/start/05-go-to-production.md +16 -1
  104. package/examples/06-triggers-and-waits.ts +4 -3
  105. package/package.json +5 -3
  106. package/src/core/Agent.ts +11 -1
  107. package/src/core/FlowSpec.ts +72 -6
  108. package/src/core/Prompt.ts +7 -1
  109. package/src/core/Runner.ts +59 -24
  110. package/src/core/Speak.ts +11 -2
  111. package/src/core/Understand.ts +12 -11
  112. package/src/index.ts +1 -0
  113. package/src/providers/ProviderAdapter.ts +10 -5
  114. package/src/providers/ZaiProvider.ts +6 -4
  115. package/src/types/agent.ts +13 -2
  116. package/src/types/index.ts +1 -0
  117. package/src/utils/phrases.ts +40 -0
  118. package/src/utils/template.ts +39 -3
@@ -8,7 +8,7 @@
8
8
  * begin ─▶ understandRequest ─(Understand)─▶ decide ─▶ advance ─(Speak)─▶ settle ─▶ finish
9
9
  */
10
10
 
11
- import type { AgentOptions, EndReason, TurnInput, TurnResult } from "../types/agent.js";
11
+ import type { AgentOptions, EndReason, PendingWakesInput, ScheduleEntry, TurnBase, TurnInput, TurnResult } from "../types/agent.js";
12
12
  import type { TokenUsage } from "../types/ai.js";
13
13
  import type { ActionResult, Branch, DoStep, Duration, Flow, IfStep, Next, Pred, PredCtx, Repeat, SayStep, Step, StepBase, TalkStep, Trigger, WaitEventStep, WaitStep } from "../types/flow.js";
14
14
  import type { History } from "../types/history.js";
@@ -49,7 +49,7 @@ export type What =
49
49
 
50
50
  /** The mutable per-turn state. `session` is a deep copy; the input is never touched. */
51
51
  export interface Turn<C = unknown, D = unknown> {
52
- readonly input: TurnInput<C, D>;
52
+ readonly input: TurnBase<C, D>;
53
53
  readonly what: What;
54
54
  readonly kind: InputKind;
55
55
  /** Message id, wake key, event key or start key: the trigger key of runs started this turn. */
@@ -171,11 +171,26 @@ export class Runner<C = unknown, D = unknown> {
171
171
  // ── Load + Ingest ─────────────────────────────────────────────────────
172
172
 
173
173
  begin(input: TurnInput<C, D>): Turn<C, D> {
174
- const now = (this.options.clock ?? (() => new Date()))();
174
+ const now = this.now();
175
+ const turn = this.open(input, describe(input, now.toISOString()), now);
176
+ if (turn.what.kind === "wake" && !input.session) {
177
+ this.ignore(turn, "no-session", turn.what.key);
178
+ return turn;
179
+ }
180
+ turn.ingesting = true;
181
+ this.ingest(turn);
182
+ turn.ingesting = false;
183
+ return turn;
184
+ }
185
+
186
+ private now(): Date {
187
+ return (this.options.clock ?? (() => new Date()))();
188
+ }
189
+
190
+ private open(input: TurnBase<C, D>, what: What, now: Date): Turn<C, D> {
175
191
  const nowIso = now.toISOString();
176
- const what = describe(input, nowIso);
177
192
  const silenced = typeof input.silenced === "string" ? input.silenced : input.silenced?.reason;
178
- const turn: Turn<C, D> = {
193
+ return {
179
194
  input,
180
195
  what,
181
196
  kind: what.kind,
@@ -204,14 +219,17 @@ export class Runner<C = unknown, D = unknown> {
204
219
  speakDone: false,
205
220
  queue: [],
206
221
  };
207
- if (what.kind === "wake" && !input.session) {
208
- this.ignore(turn, "no-session", what.key);
209
- return turn;
210
- }
211
- turn.ingesting = true;
212
- this.ingest(turn);
213
- turn.ingesting = false;
214
- return turn;
222
+ }
223
+
224
+ /** Every wake the session waits on: each parked run's own, then each silence flow's since the assistant last spoke. */
225
+ pendingWakes(input: PendingWakesInput<C, D>): ScheduleEntry[] {
226
+ // No input arrives, so nothing reads `what`: the inert wake shape only fills the field.
227
+ const turn = this.open({ ...input, sessionId: input.session.id }, { kind: "wake", key: "" }, this.now());
228
+ // `park` always sets both; the type leaves them optional, so a hand-built blob without them has no wake to give.
229
+ const parked = turn.session.runs.flatMap(({ status, waiting }) =>
230
+ status === "waiting" && waiting?.key && waiting.until ? [{ key: waiting.key, at: new Date(waiting.until) }] : [],
231
+ );
232
+ return [...parked, ...this.silenceWakes(turn)];
215
233
  }
216
234
 
217
235
  private ingest(turn: Turn<C, D>): void {
@@ -407,9 +425,11 @@ export class Runner<C = unknown, D = unknown> {
407
425
  }
408
426
  session.runs.push(run);
409
427
  turn.started.push({ runId: run.id, flowId: flow.id, anchor, dedupeKey });
410
- const after = opts.trigger && "event" in opts.trigger ? opts.trigger.after : undefined;
411
- if (after) {
412
- const at = this.snap(turn, new Date(turn.now.getTime() + parseDuration(after)), opts.trigger && "event" in opts.trigger ? opts.trigger.businessHours : undefined);
428
+ const eventTrigger = opts.trigger && "event" in opts.trigger ? opts.trigger : undefined;
429
+ const after = eventTrigger?.after;
430
+ const at = this.snap(turn, new Date(turn.now.getTime() + (after ? parseDuration(after) : 0)), eventTrigger?.businessHours);
431
+ // With no `after`, only a closed hour parks the start: inside working hours the snap returns now.
432
+ if (after || at.getTime() > turn.now.getTime()) {
413
433
  this.park(turn, run, { kind: "timer", key: `${run.id}:start:${at.getTime()}` }, at);
414
434
  this.outcome(turn, run, { kind: "wait", status: "waiting", code: "awaiting-trigger", until: at.toISOString() });
415
435
  }
@@ -508,8 +528,7 @@ export class Runner<C = unknown, D = unknown> {
508
528
  const floorRun = this.floorRun(turn);
509
529
  const floorFlow = floorRun && this.flows.get(floorRun.flowId);
510
530
  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;
531
+ const messageFlows = !floorRun && this.startsUnscored(turn, eligible) ? [] : eligible;
513
532
  const mentionFlows = this.mentionFlows()
514
533
  .filter(({ flow, trigger }) => trigger.mention.length > 0 && this.repeatAllows(turn, flow, trigger, turn.triggerKey))
515
534
  .map(({ flow }) => flow);
@@ -562,6 +581,14 @@ export class Runner<C = unknown, D = unknown> {
562
581
  return this.messageFlows(turn, (list) => list.length > 0);
563
582
  }
564
583
 
584
+ /**
585
+ * The lone eligible flow starts without a score only when a low score would have nowhere
586
+ * else to send the message: no catch-all passes and the idle speaker is silent (S9).
587
+ */
588
+ private startsUnscored(turn: Turn<C, D>, eligible: Flow<C, D>[]): boolean {
589
+ return eligible.length === 1 && this.options.idle === "silent" && this.messageFlows(turn, (list) => list.length === 0).length === 0;
590
+ }
591
+
565
592
  private messageFlows(turn: Turn<C, D>, accept: (list: string[]) => boolean): Flow<C, D>[] {
566
593
  const out: Flow<C, D>[] = [];
567
594
  for (const flow of this.flows.values()) {
@@ -605,7 +632,7 @@ export class Runner<C = unknown, D = unknown> {
605
632
  const current = scores[asker.flowId] ?? 0;
606
633
  const top = best(eligible.filter((f) => f.id !== asker.flowId));
607
634
  if (top && top.score >= current + ROUTE_STICKY && top.score >= ROUTE_MIN) route = top.flow;
608
- } else if (eligible.length === 1) {
635
+ } else if (this.startsUnscored(turn, eligible)) {
609
636
  route = eligible[0];
610
637
  } else {
611
638
  const top = best(eligible);
@@ -1139,14 +1166,21 @@ export class Runner<C = unknown, D = unknown> {
1139
1166
  return new Date(reset !== undefined && reset > ladder ? reset : ladder);
1140
1167
  }
1141
1168
 
1142
- /** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
1169
+ /** The assistant spoke this turn: its silence wakes replace the ones its last words set. */
1143
1170
  private armSilence(turn: Turn<C, D>): void {
1171
+ const previous = turn.original?.lastAssistantAt;
1172
+ if (turn.session.lastAssistantAt === previous) return;
1173
+ turn.schedule.push(...this.silenceWakes(turn, previous));
1174
+ }
1175
+
1176
+ /** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
1177
+ private silenceWakes(turn: Turn<C, D>, previous?: string): ScheduleEntry[] {
1144
1178
  const { session } = turn;
1145
1179
  const { lastAssistantAt, lastUserAt } = session;
1146
- if (!lastAssistantAt || lastAssistantAt === turn.original?.lastAssistantAt) return;
1147
- if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt)) return;
1180
+ if (!lastAssistantAt) return [];
1181
+ if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt)) return [];
1148
1182
  const ms = Date.parse(lastAssistantAt);
1149
- const previous = turn.original?.lastAssistantAt;
1183
+ const wakes: ScheduleEntry[] = [];
1150
1184
  for (const flow of this.flows.values()) {
1151
1185
  const trigger = (flow.on ?? []).find((t): t is SilenceTrigger<C, D> => "silence" in t);
1152
1186
  if (!trigger) continue;
@@ -1154,12 +1188,13 @@ export class Runner<C = unknown, D = unknown> {
1154
1188
  if (trigger.if && !this.holds(trigger.if, turn, this.draftRun(turn, flow, "silence", key))) continue;
1155
1189
  if (!this.repeatAllows(turn, flow, trigger, key)) continue;
1156
1190
  const at = this.snap(turn, new Date(ms + parseDuration(trigger.silence)), trigger.businessHours);
1157
- turn.schedule.push({
1191
+ wakes.push({
1158
1192
  key: `silence:${flow.id}:${session.id}:${ms}`,
1159
1193
  at,
1160
1194
  ...(previous ? { replaces: `silence:${flow.id}:${session.id}:${Date.parse(previous)}` } : {}),
1161
1195
  });
1162
1196
  }
1197
+ return wakes;
1163
1198
  }
1164
1199
 
1165
1200
  // ── Return (design §4.8) ──────────────────────────────────────────────
package/src/core/Speak.ts CHANGED
@@ -78,8 +78,17 @@ function deferralOf(error: unknown): Deferral {
78
78
  // A usage window that says when it reopens is worth exactly one wake, then.
79
79
  // Without that number, waiting is guessing, and the ladder guesses in minutes
80
80
  // at a limit measured in hours.
81
- const retryable = RETRY_KINDS.has(kind) || (kind === "quota" && reset !== undefined);
82
- return { code: DEFER_CODE[kind], retryable, ...(reset !== undefined ? { resetAtMs: reset } : {}) };
81
+ const inferred = RETRY_KINDS.has(kind) || (kind === "quota" && reset !== undefined);
82
+ // Unless the provider said so outright. `x-should-retry` is the one answer
83
+ // nobody has to infer, and core already puts it above its own transience
84
+ // test — a 503 that says don't costs five wakes and ten model calls here if
85
+ // this reads the kind instead.
86
+ const stated = error instanceof ProviderError ? error.shouldRetry : undefined;
87
+ return {
88
+ code: DEFER_CODE[kind],
89
+ retryable: stated ?? inferred,
90
+ ...(reset !== undefined ? { resetAtMs: reset } : {}),
91
+ };
83
92
  }
84
93
  /** Gemini rejects any other envelope property name. */
85
94
  const WIRE_NAME = /^[a-zA-Z0-9_-]+$/;
@@ -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";
@@ -43,12 +44,9 @@ export class Understand<C = unknown, D = unknown> {
43
44
  const candidates = candidateFlows(req);
44
45
  const onlyRouting =
45
46
  req.mentionFlows.length === 0 && req.branches.length === 0 && Object.keys(req.fields).length === 0;
46
- if (onlyRouting && candidates.length <= 1) {
47
- // One eligible flow and nobody on the floor: it starts, no scoring.
48
- // Otherwise there is nothing to compare or extract.
49
- const only = candidates[0];
50
- return only && !req.floor ? { ...empty(0), flows: { [only.id]: 100 } } : empty(0);
51
- }
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);
52
50
 
53
51
  const aliases = new Aliases();
54
52
  const jsonSchema = buildEnvelope(req, candidates, aliases);
@@ -245,8 +243,9 @@ function flowsSection<C, D>(candidates: Flow<C, D>[], aliases: Aliases, t: (text
245
243
  ];
246
244
  candidates.forEach((flow, i) => {
247
245
  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("; ")}`);
246
+ const { counts, excludes } = splitPhrases(triggerPhrases(flow, "message"));
247
+ if (counts.length) lines.push(` The customer: ${counts.map(t).join("; ")}`);
248
+ if (excludes.length) lines.push(` Score 0 when: ${excludes.map(t).join("; ")}`);
250
249
  });
251
250
  lines.push(
252
251
  "",
@@ -265,12 +264,14 @@ function mentionsSection<C, D>(flows: Flow<C, D>[], aliases: Aliases, t: (text:
265
264
  const lines = [
266
265
  "## Things the customer may mention",
267
266
  "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.",
267
+ "Be conservative: true needs clear, explicit evidence in the message. The phrases under an item are alternatives; one match is enough. " +
268
+ "A 'Does not count when' line overrides a match: if one of those fits, answer false.",
269
269
  ];
270
270
  for (const flow of flows) {
271
271
  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("; ")}`);
272
+ const { counts, excludes } = splitPhrases(triggerPhrases(flow, "mention"));
273
+ if (counts.length) lines.push(` Counts when: ${counts.map(t).join("; ")}`);
274
+ if (excludes.length) lines.push(` Does not count when: ${excludes.map(t).join("; ")}`);
274
275
  const defs = extractDefs(flow);
275
276
  if (defs) {
276
277
  lines.push(" When true, also extract:");
package/src/index.ts CHANGED
@@ -137,6 +137,7 @@ export type {
137
137
  ParamDef,
138
138
  ParamDefs,
139
139
  Participant,
140
+ PendingWakesInput,
140
141
  Pred,
141
142
  PredCtx,
142
143
  ProviderCapabilities,
@@ -101,11 +101,16 @@ export interface RequestConfig {
101
101
  maxTokens?: number;
102
102
  stopSequences?: string[];
103
103
  /**
104
- * How hard the model thinks, bound as this provider's default. Absent means
105
- * the model's own dynamic thinking, which is what every shape does when the
106
- * field is never sent — so `"none"` is the only way to say *don't*, and it
107
- * matters most under a small `maxTokens`, where thinking tokens come out of
108
- * the same budget as the answer and can consume all of it.
104
+ * How hard the model thinks, bound as this provider's default. What absent
105
+ * means is the shape's own business, not one rule: on Gemini it is the
106
+ * model's dynamic thinking; on the OpenAI and OpenRouter dialects nothing is
107
+ * sent and not thinking is the default anyway; on the Anthropic shape — so
108
+ * `AnthropicProvider` and `ZaiProvider` — `@providerkit/core` resolves an
109
+ * absent effort to `"none"`, and Z.ai is sent an explicit disabled marker
110
+ * because its endpoint reads silence as thinking ON. Set a level to ask for
111
+ * thinking where it is off; it matters most under a small `maxTokens`, where
112
+ * thinking tokens come out of the same budget as the answer and can consume
113
+ * all of it.
109
114
  */
110
115
  effort?: Effort;
111
116
  }
@@ -6,10 +6,12 @@
6
6
  * `@providerkit/core` (adapter + preset); this file is the constructor, the
7
7
  * family default for cheap high-volume work.
8
8
  *
9
- * Dialect notes (measured 2026-09-13, see the core preset for detail):
10
- * model ids are BARE (`glm-5.3-flash`, not `z-ai/glm-5.3-flash`), and
11
- * "no thinking" must be an explicit marker — silence means the model default,
12
- * which is thinking ON.
9
+ * Dialect notes (see the core preset for detail): model ids are BARE
10
+ * (`glm-5.3-flash`, not `z-ai/glm-5.3-flash`), and the endpoint reads an
11
+ * absent `thinking` field as thinking ON, so the preset says "no" out loud.
12
+ * That makes thinking OFF here by default (measured 2026-09-21: a call with no
13
+ * `config.effort` sends `thinking: { type: "disabled" }`); ask for it with
14
+ * `config: { effort: 'high' }`.
13
15
  */
14
16
 
15
17
  import { createZaiCodingProvider, type FallbackOptions, type Provider } from "@providerkit/core";
@@ -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 } }`. */
@@ -108,6 +112,13 @@ export type TurnKind =
108
112
 
109
113
  export type TurnInput<C = unknown, D = unknown> = TurnBase<C, D> & TurnKind;
110
114
 
115
+ /** A saved session and the host inputs a turn on it would carry: what `pendingWakes` reads. */
116
+ export type PendingWakesInput<C = unknown, D = unknown> = ContextField<C> & {
117
+ session: Session<D>;
118
+ anchors?: TurnBase<C, D>["anchors"];
119
+ claims?: TurnBase<C, D>["claims"];
120
+ };
121
+
111
122
  // ── turn() result ───────────────────────────────────────────────────────
112
123
 
113
124
  export interface OutboundMessage {
@@ -123,7 +134,7 @@ export interface OutboundMessage {
123
134
  stepId?: string;
124
135
  }
125
136
 
126
- /** A wake to enqueue with `jobId = key`; at fire time call `turn({ wake: key })`. */
137
+ /** A wake to enqueue, keyed by `key` (encode it as a queue job id: BullMQ refuses a `:`); at fire time call `turn({ wake: key })`. */
127
138
  export interface ScheduleEntry {
128
139
  key: string;
129
140
  at: Date;
@@ -10,6 +10,7 @@ export type {
10
10
  EndReason,
11
11
  Idle,
12
12
  OutboundMessage,
13
+ PendingWakesInput,
13
14
  ScheduleEntry,
14
15
  Silenced,
15
16
  TurnBase,
@@ -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,14 @@
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 the host answered with nothing is a different case: it knows the
10
+ * field and knows it is blank. Then the placeholder goes and `tidy` closes the
11
+ * gap it left, so "Ola {{context.lead.name}}, tudo bem?" sends "Ola, tudo bem?"
12
+ * and never "Ola , tudo bem?". Two things count as blank: an empty string, and
13
+ * a path that walks THROUGH a null — `context.lead` being `null` means there
14
+ * is no lead, so "the lead's name" is blank, not mistyped. A null at the end of
15
+ * a path is still unknown: a field that collected nothing keeps its braces.
8
16
  */
9
17
 
10
18
  export interface TemplateScope {
@@ -15,11 +23,38 @@ export interface TemplateScope {
15
23
 
16
24
  const PLACEHOLDER = /\{\{\s*([^}\s]+)\s*\}\}/g;
17
25
 
26
+ /** The path ran into a `null` on its way down: the container the host named is absent. */
27
+ const ABSENT = Symbol("absent");
28
+
18
29
  export function render(template: string, scope: TemplateScope): string {
19
- return template.replace(PLACEHOLDER, (match, path: string) => {
30
+ let emptied = false;
31
+ const out = template.replace(PLACEHOLDER, (match, path: string) => {
20
32
  const value = lookup(scope, path.split("."));
21
- return value === undefined || value === null ? match : stringify(value);
33
+ if (value === ABSENT) {
34
+ emptied = true;
35
+ return "";
36
+ }
37
+ if (value === undefined || value === null) return match;
38
+ const text = stringify(value);
39
+ if (text === "") emptied = true;
40
+ return text;
22
41
  });
42
+ return emptied ? tidy(out) : out;
43
+ }
44
+
45
+ /**
46
+ * Close the hole an empty value leaves: a doubled space, a space before
47
+ * punctuation, a space at the end of a line.
48
+ *
49
+ * Deliberately narrow. It only runs on a string where something substituted to
50
+ * "", and it only collapses a run of spaces that follows a visible character,
51
+ * so indentation in a markdown list survives.
52
+ */
53
+ function tidy(text: string): string {
54
+ return text
55
+ .replace(/(?<=\S)[ \t]{2,}/g, " ")
56
+ .replace(/ +([,.;:!?\u2026])/g, "$1")
57
+ .replace(/[ \t]+$/gm, "");
23
58
  }
24
59
 
25
60
  /** `render` over every string inside a value, recursively. Non-strings pass through. */
@@ -37,7 +72,8 @@ export function renderDeep<T>(value: T, scope: TemplateScope): T {
37
72
  function lookup(root: unknown, keys: string[]): unknown {
38
73
  let current = root;
39
74
  for (const key of keys) {
40
- if (current === null || typeof current !== "object") return undefined;
75
+ if (current === null) return ABSENT;
76
+ if (typeof current !== "object") return undefined;
41
77
  current = (current as Record<string, unknown>)[key];
42
78
  }
43
79
  return current;