@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
@@ -65,7 +65,7 @@ async function onMessage(sessionId: string, context: unknown, history: History,
65
65
  if (r.changed) {
66
66
  await store.save(r.session, session?.version ?? 0); // throws SessionConflictError when another turn saved first: drop this result and run the turn again
67
67
  for (const m of r.messages) await send(m.text, { after: m.afterMs, key: m.key });
68
- for (const s of r.schedule) await queue.add({ jobId: s.key, at: s.at });
68
+ for (const s of r.schedule) await queue.add({ jobId: encodeURIComponent(s.key), at: s.at }); // BullMQ refuses a ':' in a custom id
69
69
  }
70
70
  }
71
71
  ```
@@ -253,7 +253,10 @@ const f = falai().fields({ nome: { type: 'string' } });
253
253
 
254
254
  const concorrente = f.flow({
255
255
  id: 'concorrente', name: 'Lead falou de concorrente',
256
- on: [{ mention: ['o lead cita ou compara com um concorrente'], extract: { trecho: { type: 'string' } }, repeat: 'once' }],
256
+ on: [{
257
+ mention: ['o lead cita ou compara com um concorrente', '!o lead fala do nosso próprio produto'],
258
+ extract: { trecho: { type: 'string' } }, repeat: 'once',
259
+ }],
257
260
  steps: [
258
261
  { id: 'tag', do: 'add_tags', with: { tags: ['concorrente'] } },
259
262
  { id: 'avisa', do: 'notify', with: { recipient: 'owner', message: '{{data.nome}} falou de concorrente: "{{input.trecho}}"' } },
@@ -263,7 +266,7 @@ const concorrente = f.flow({
263
266
 
264
267
  | Signal facet | v4 |
265
268
  |---|---|
266
- | `when[]` with `!` exclusions | `mention: [...]`; write the exclusion into the phrase |
269
+ | `when[]` with `!` exclusions | `mention: [...]`, exclusions and all — a `!` phrase still rules the trigger out. Copy the list across unchanged |
267
270
  | `if` | trigger `if`; sees `input` after `extract` |
268
271
  | `extract` | trigger `extract` → `run.input` → `{{input.x}}`; never written to `data` |
269
272
  | `phase: 'pre'` + `halt` + `reply` | a `say` first step; another run's `say` silences the floor's reply that turn |
@@ -300,7 +303,7 @@ const retomar = f.flow({
300
303
  });
301
304
  ```
302
305
 
303
- - `wait: '3s'` (10 s or less, and the next step is a `say` or a talk step) becomes `afterMs` on that message in the same turn; every other wait parks the run (stops it until a wake) and puts `{ key, at }` in `schedule[]`. Enqueue the wake with `jobId = key` and call `turn({ wake: key })` when it fires. The framework never cancels a wake itself: a stale one is ignored (`changed: false`). A re-armed silence wake names the one it supersedes in `replaces`; removing that job is optional.
306
+ - `wait: '3s'` (10 s or less, and the next step is a `say` or a talk step) becomes `afterMs` on that message in the same turn; every other wait parks the run (stops it until a wake) and puts `{ key, at }` in `schedule[]`. Enqueue the wake with `encodeURIComponent(key)` as the job id (BullMQ refuses a `:` in a custom id) and call `turn({ wake: key })` when it fires. The framework never cancels a wake itself: a stale one is ignored (`changed: false`). A re-armed silence wake names the one it supersedes in `replaces`; removing that job is optional.
304
307
  - `on: [{ event: 'stage_entered', after: '1h' }]` starts a run when your code calls `turn({ event, payload, key })`. Declare events with `f.event<Payload>({ direction? })`: `inbound` counts as the customer speaking, `outbound` as the assistant.
305
308
  - `wait: { event: 'meeting_booked', upTo: '7d' }` parks until the event arrives.
306
309
  - Runs inside a session are concurrent; at most one is asking a question. A timer-started talk step suspends the current asker and hands the floor back when it is done.
@@ -383,6 +386,7 @@ const session = migrateSession(rowBlob, {
383
386
  - `version` is 0: the session has no row in the v4 table yet, so your usual `store.save(session, session.version)` is the insert.
384
387
  - `pendingDirective` is dropped.
385
388
  - A blob that is neither v4 nor a recognisable 3.x state throws `InvalidSessionError`; a corrupt row can no longer become a fresh conversation silently.
389
+ - 3.x did not record when the assistant last spoke, so a lifted session has no `lastAssistantAt` and arms no silence follow-up until the assistant speaks again. At cutover, set `session.lastAssistantAt` and `session.lastUserAt` from your messages table, save, and enqueue `agent.pendingWakes({ session, context })`: a conversation that was quiet at cutover then gets its follow-up on time.
386
390
 
387
391
  Add a test that loads one real (anonymised) row per product and asserts the run's `stepId` and the carried claims.
388
392
 
@@ -407,13 +411,14 @@ Host actions, events and conditions are registered once on the agent and referen
407
411
  | `agent.dispatch`, `pendingDirective`, `Directive`, `flow.merge`, `flow.validate` | `then` / `else` on steps |
408
412
  | `Flow` class, `Step` class, `flow` namespace, `FlowOptions`, `StepOptions` | plain objects: `Flow`, `Step` |
409
413
  | `title`, `when`, `if`, `reentrant`, `requiredFields`, `optionalFields`, `onComplete`, flow `hooks` | `id` + `name`, `on[]`, `repeat`, `clearOnStart`, `onEnd`, `while` |
410
- | `requires`, `skip`, `auto`, `reply`, step `hooks`, `prepare`, `finalize` | known-field skipping, `maxAsks`, `do`, `if`, `wait`, `say` |
414
+ | `skip`, `auto`, `reply`, step `hooks`, `prepare`, `finalize` | known-field skipping, `maxAsks`, `do`, `wait`, `say` |
415
+ | `requires` | an `if` step: `{ if: { known: [...] }, then: '<step>', else: 'end' }`. Not known-field skipping — that answers "do I still need to ask?", while `requires` answers "may this step run at all?". They differ exactly when the customer never answers: `maxAsks` retires the field and the step proceeds with it unknown. |
411
416
  | `Signal`, `SignalContext`, `SignalFiring`, `signals`, `signalBatchSize`, `triggeredSignals` | `mention` flows, `repeat`, `claims` |
412
417
  | `ToolContext.updateContext / updateData / setField / dispatch`, `ToolResult.dataUpdate / contextUpdate / directive`, `ToolManager`, `ToolScope`, tool config helpers | `Tool.handler(args, ctx) → { value?, data? }` |
413
418
  | `PersistenceAdapter`, `SessionRepository`, `MessageRepository`, `PersistenceManager`, `SessionManager`, `restoreSession`, `createPersistedState`, `enterFlow`, `enterStep`, `completeCurrentFlow`, `mergeCollected` | `Store`, the seven `*Store` classes, `migrateSession` |
414
419
  | `SessionState`, `CollectedStateData`, `SessionData` | `Session` |
415
420
  | `AgentResponse.executedSteps / stoppedReason / endedFlows / appliedInstructions / isFlowComplete` | `TurnResult.outcomes / started / ended / skipped / messages / schedule / llmCalls` |
416
- | `Template` as a function, `TemplateContext`, `ConditionEvaluator`, `ConditionWhen`, `ConditionIf`, `!` exclusions | `Template = string` with `{{data.x}}` `{{context.x}}` `{{input.x}}`; `Pred` (function or JSON) |
421
+ | `Template` as a function, `TemplateContext`, `ConditionEvaluator`, `ConditionWhen`, `ConditionIf` | `Template = string` with `{{data.x}}` `{{context.x}}` `{{input.x}}`; `Pred` (function or JSON). **`!` exclusions are not gone** — a phrase opening with `!` still rules a trigger out. Copy the list across unchanged. |
417
422
  | `Term`, `terms` | put the glossary in `knowledgeBase` or an instruction |
418
423
  | `Instruction.enabled / tags / metadata` | filter before passing |
419
424
  | `promptCache`, `PromptSectionCache`, `PromptCacheConfig` | gone; every prompt is built per call. `compaction` stays and runs once per turn on the history you pass |
@@ -437,7 +442,7 @@ Then, in this order:
437
442
  1. Convert stored flows and signal rules to `FlowSpec` rows. Keep talk-step ids; give each migrated signal flow `id = signal key`.
438
443
  2. Register your actions, events and conditions on the agent.
439
444
  3. Replace the `respond` call site with load → `turn` → save + messages + schedules in one transaction.
440
- 4. Wire wakes (`jobId = key`, `turn({ wake })` at fire time) and host events.
445
+ 4. Wire wakes (job id `encodeURIComponent(key)`, `turn({ wake })` at fire time) and host events. At cutover, enqueue `agent.pendingWakes()` for every lifted session that was quiet.
441
446
  5. Put `migrateSession` in your deserializer and let it throw on garbage.
442
447
  6. Delete the automation engine, the follow-up sweep and the second composer.
443
448
 
@@ -116,7 +116,7 @@ f.action<const P extends ParamDefs>(def: {
116
116
 
117
117
  ### Behaviour
118
118
 
119
- - `with` is rendered before `run` sees it. `{{data.x}}`, `{{context.x}}` and `{{input.x}}` are replaced inside every string, at any depth. A path that resolves to nothing keeps its placeholder, so a typo stays visible.
119
+ - `with` is rendered before `run` sees it. `{{data.x}}`, `{{context.x}}` and `{{input.x}}` are replaced inside every string, at any depth. An unknown path keeps its placeholder, so a typo stays visible. A blank one drops out instead and the gap it left in the sentence closes — an empty string, or a path through a `null` (`{{context.lead.name}}` with no lead). A `null` at the end of a path is unknown, not blank.
120
120
  - `with` is checked when the agent is built, not on the turn that reaches the step. A missing required parameter, an unknown parameter, or a value outside `enum` throws `FlowConfigurationError`. So does a wrong type: `"3"` is not a number, because values are never coerced. A string that contains `{{` skips the `enum` check, because its value is only known at run time.
121
121
  - Actions run in the Run phase, by code, with zero model calls. They run while `silenced` too.
122
122
  - A `do` step whose action name is not registered throws at build. If the registry changed under a running agent, the step reports `code: 'action-failed'` with `detail: 'unknown action "notify"'`.
@@ -220,7 +220,7 @@ An event turn never spends an understand call. It costs one speak call (plus too
220
220
 
221
221
  1. **Direction.** `'inbound'` sets `lastUserAt` to now and resolves reply waits: every run parked on a timer `wait` that has an `else` resumes with `code: 'replied'` and follows a matching `if` branch's `then`, else `else`. `'outbound'` sets `lastAssistantAt` to now, which re-arms `silence` triggers at the end of the turn.
222
222
  2. **Waiting runs.** Every run parked on `wait: { event: name }` for this name resumes with `code: 'event-arrived'` and follows `then`. The first one to resume takes the floor for this turn.
223
- 3. **Triggers.** Every flow with `on: [{ event: name }]` goes through the start order: trigger `if`, `repeat` (default `'always'` for events), the hop cap, one live run per flow and anchor. With `after`, the run parks first (`code: 'awaiting-trigger'`, wake key `${runId}:start:${atMs}`) and enters its first step when the wake fires; `businessHours: true` snaps that time forward through the agent's `businessHours` function.
223
+ 3. **Triggers.** Every flow with `on: [{ event: name }]` goes through the start order: trigger `if`, `repeat` (default `'always'` for events), the hop cap, one live run per flow and anchor. With `after`, the run parks first (`code: 'awaiting-trigger'`, wake key `${runId}:start:${atMs}`) and enters its first step when the wake fires; `businessHours: true` snaps that time forward through the agent's `businessHours` function. With `businessHours: true` and no `after`, an event that arrives outside working hours parks the run the same way until the next working moment.
224
224
 
225
225
  `wait: { event, upTo }` in a step parks the run for at most `upTo` (default `'30d'`, from `src/core/Runner.ts`). If the event never comes, the line carries `code: 'no-event'` and the run follows `else`, or ends when there is none.
226
226
 
@@ -77,7 +77,7 @@ class Agent<C, D> {
77
77
  | `tools` | `Tool<C, D>[]` | `[]` | Functions the model may call while it speaks. See [Tool](tool.md). |
78
78
  | `instructions` | `Instruction<C, D>[]` | `[]` | Agent-level rules, rendered into every speak call. See [Instruction](instruction.md). |
79
79
  | `knowledgeBase` | `Record<string, unknown>` | none | Any JSON the model should know. Rendered as nested bullets. |
80
- | `idle` | `Idle<C, D>` | `{ prompt: "" }` | The one speaker that is not a step. Answers a message when no run holds the floor. `'silent'` mutes it. |
80
+ | `idle` | `Idle<C, D>` | `{ prompt: "" }` | The one speaker that is not a step. Answers a message when no run holds the floor. `'silent'` mutes it; then a lone message flow with no catch-all starts without being scored. |
81
81
  | `clock` | `Clock` | `() => new Date()` | Returns "now". Tests pass `fakeClock(iso)`. |
82
82
  | `businessHours` | `BusinessHours<C>` | none | `(at, { context }) => Date`. Moves a timer forward to the next working moment when a trigger or wait says `businessHours: true`. |
83
83
  | `maxToolLoops` | `number` | `5` | Tool rounds per speak call. `0` disables tools. After the last round the model is asked once more without tools, so a message always comes back. |
@@ -94,6 +94,10 @@ class Agent<C, D> {
94
94
 
95
95
  `turnStream(input)` runs the same turn and yields `{ delta: string }` chunks while the model phrases the reply, then one `{ done: true, result: TurnResult }`. When nobody speaks, only the last chunk comes. See [Streaming](../guides/streaming.md).
96
96
 
97
+ ## pendingWakes()
98
+
99
+ `pendingWakes({ session, context, anchors?, claims? })` returns the `ScheduleEntry[]` a saved session is waiting on: each parked run's wake, and each silence flow's counted from `session.lastAssistantAt`, none once the customer wrote after it. The trigger's `if` and `repeat` are judged on the `context`, `anchors` and `claims` you pass, as in a turn. It changes nothing and spends no model call; the entries carry no `replaces`. Use it when a queue lost its jobs, and after `migrateSession`, since a 3.x blob has no `lastAssistantAt` and so no silence wake until the assistant speaks again.
100
+
97
101
  ## TurnInput
98
102
 
99
103
  Every input carries the common fields, plus exactly one of the four kinds.
@@ -105,7 +109,7 @@ Every input carries the common fields, plus exactly one of the four kinds.
105
109
  | `sessionId` | `string` | The conversation's id. A first turn creates the session under this id. |
106
110
  | `session` | `Session<D>` | The stored session, as the store returned it. Absent on a first turn. A `wake` without a session is ignored. |
107
111
  | `context` | `C` | Your ambient data for this turn. Required unless `C` allows `undefined` (`falai()` with no generic). |
108
- | `history` | `History` | The conversation so far. Pass it on every input kind, wakes included; both calls read it. Without it the framework falls back to `session.history`, then to an empty list. |
112
+ | `history` | `History` | The conversation before this input. Pass it on every input kind, wakes included; both calls read it. Leave out the message this turn carries: both calls quote it on their own, so a history that ends with it is read twice. Store the message after the turn, not before. Without `history` the framework falls back to `session.history`, then to an empty list. |
109
113
  | `silenced` | `Silenced` | Your reason the assistant cannot speak right now. See below. |
110
114
  | `anchors` | `Record<string, { key: string; lastInboundAt?: string }>` | The host anchors this session belongs to, by name, e.g. `{ lead: { key: 'lead:456', lastInboundAt } }`. A flow with `anchor: 'lead'` keys its runs and claims by `anchors.lead.key`. |
111
115
  | `claims` | `{ held: Record<string, string>; active: string[] }` | Claims from the customer's other sessions: `held` maps a dedupe key to the ISO time it was taken; `active` lists live `${flowId}:${anchor}` pairs. A flow already active elsewhere is skipped with `code: 'already-running'`. |
@@ -134,7 +138,7 @@ A string closes the gate: `do` steps still run, nothing is phrased, zero model c
134
138
  | `session` | `Session<D>` | The session after this turn. `version` is unchanged; the host saves it with the version it loaded and the store bumps it. |
135
139
  | `changed` | `boolean` | `false` means save nothing and send nothing: the input was ignored or nothing moved. |
136
140
  | `messages` | `OutboundMessage[]` | What to send, in order. |
137
- | `schedule` | `ScheduleEntry[]` | Wakes to enqueue with `jobId = key`. At fire time call `turn({ wake: key })`. |
141
+ | `schedule` | `ScheduleEntry[]` | Wakes to enqueue, with `encodeURIComponent(key)` as the job id. At fire time call `turn({ wake: key })`. |
138
142
  | `outcomes` | `StepOutcome[]` | One line per step this turn, for your execution log. See [Outcomes](outcomes.md). |
139
143
  | `started` | `Array<{ runId; flowId; anchor; dedupeKey }>` | Runs that started this turn. |
140
144
  | `ended` | `Array<Run & { reason: EndReason }>` | Runs that ended, with the run's last state and why: `'end'`, `'flow'`, `'reset'`, `'skipped'`, `'failed'` or `'replaced'`. |
@@ -183,7 +187,7 @@ if (r.usage) {
183
187
 
184
188
  | Field | Type | Meaning |
185
189
  |---|---|---|
186
- | `key` | `string` | The wake key. Use it as the job id and pass it back as `turn({ wake: key })`. |
190
+ | `key` | `string` | The wake key. Pass it back as `turn({ wake: key })`. As a queue job id, encode it: BullMQ refuses a custom id with a `:` in it, and every key has one. |
187
191
  | `at` | `Date` | When to fire. |
188
192
  | `replaces` | `string` | An earlier wake this one supersedes. Removing it is best effort; a stale wake is ignored anyway. |
189
193
 
@@ -132,7 +132,7 @@ Every message has the form `[FlowConfigurationError] <where>: <what>. <fix>`, wh
132
132
  | Bad duration | `wait has duration "5 min", which does not parse` (also `silence`, `after`, `repeat.cooldown`, `wait.upTo`) | Write a number and a unit: "30s", "5m", "24h" or "3d". |
133
133
  | Dangling jump | `then points at step "x", which does not exist` (also `else`, `onFail`, `branches[n].then`) | Use an existing step id or "end". |
134
134
  | Missing parameter | `action "notify" needs parameter "message"` | Add it to `with`. |
135
- | Wrong parameter type | `parameter "tags" of action "add_tags" must be a list of string, got string` | Values are not coerced; write the right type. |
135
+ | Wrong parameter type | `parameter "tags" of action "add_tags" must be a list of strings, got string` | Values are not coerced; write the right type. |
136
136
  | Extra parameter | `action "notify" has no parameter "to"` | Remove it or fix the name. |
137
137
  | Branch without a test | `branches[0] has neither when nor if` | Give the branch an AI condition (when) or a code one (if). |
138
138
  | Backward `if` with no `else` | `"if" jumps back to "quem" with no else` | Add else so the false branch has somewhere to go. |
@@ -185,7 +185,7 @@ Base URL `https://openrouter.ai/api`, chat completions with `json_schema`.
185
185
 
186
186
  Pass `providerOrder` to choose the hosts yourself. There is no way to say it inside `model`: OpenRouter answers `400 "z-ai/glm-5.3-flash@novita is not a valid model ID"`.
187
187
 
188
- One caveat worth measuring for your own model: through this gateway, `z-ai/glm-5.3-flash` put "somos umas 30 pessoas" in the wrong band of a four-value enum on most attempts, in every JSON mode and routing tried, while the same model on [ZaiProvider](#zaiprovider) and `deepseek-chat` got it right every time. Run `bun run eval:live --only openrouter` against the model you ship.
188
+ **The route changes the answers, so measure your own model here.** Through this gateway, `z-ai/glm-5.3-flash` put "somos umas 30 pessoas" in the wrong band of a four-value enum on most attempts, in every JSON mode and routing tried, while the same model on [ZaiProvider](#zaiprovider) and `deepseek-chat` got it right every time. Over the 40-case understand eval on 2026-09-21 the same split holds: routing agreement 93% either way, but field agreement 86% (12/14) through OpenRouter against 100% (14/14) on `ZaiProvider`, and a median call three to five times slower. Prefer a model's own endpoint where you have one; run `bun run eval:understand` and `bun run eval:live --only openrouter` against the model you ship.
189
189
 
190
190
  ## DeepSeekProvider
191
191
 
@@ -236,7 +236,7 @@ import { ZaiProvider } from "@falai/agent";
236
236
  const zai = new ZaiProvider({ apiKey: process.env.ZAI_API_KEY ?? "" }); // model defaults to glm-5.3-flash
237
237
  ```
238
238
 
239
- The flat-rate Z.ai Coding Plan hosts GLM on an Anthropic-compatible endpoint. Model ids are bare, and "no thinking" has to be said with `config: { effort: "none" }`: leaving `effort` unset means the model's default, which is thinking on.
239
+ The flat-rate Z.ai Coding Plan hosts GLM on an Anthropic-compatible endpoint. Model ids are bare, and thinking is off unless you ask for it: the endpoint reads an absent `thinking` field as thinking on, so `@providerkit/core` says "no" out loud for you. Measured 2026-09-21: a call with no `config.effort` sends `thinking: { type: "disabled" }`, the same body `effort: "none"` produces. Ask for thinking with `config: { effort: "high" }`.
240
240
 
241
241
  ## FallbackAiProvider
242
242
 
@@ -49,6 +49,26 @@ The run takes the conversation: it becomes the asker and holds the floor — onl
49
49
 
50
50
  The run starts beside the conversation. It is meant for `do` and `say` steps: it does not get routed to. A talk step in it behaves like any talk step and takes the floor.
51
51
 
52
+ ### Phrases that rule a trigger out
53
+
54
+ Phrases are alternatives — one match is enough — so a list alone can only say "any of these". A phrase that opens with `!` says the opposite: it stops the trigger, whatever else matched.
55
+
56
+ ```ts fragment
57
+ on: [{
58
+ mention: [
59
+ 'o cliente pede para falar com uma pessoa',
60
+ '!o cliente só concorda com o horário oferecido', // "pode sim" is a yes to the meeting
61
+ '!o cliente menciona outra pessoa sem pedir atendimento',
62
+ ],
63
+ }]
64
+ ```
65
+
66
+ Without the two exclusions, a customer answering "pode sim" to an offer of a meeting reads as a request for a human, because that sentence really does mention a person. The model is given the two lists separately and told that an exclusion overrides a match.
67
+
68
+ Works in `message` (an exclusion scores the flow 0), in `mention` (an exclusion answers false), and in an instruction's `when`. Whitespace around a phrase is trimmed, a bare `"!"` is ignored, and a `!` anywhere but the first character is ordinary text.
69
+
70
+ A trigger whose phrases are *all* exclusions can never fire, so `validateFlow` rejects it. For a flow that should catch everything else, use `message: []`.
71
+
52
72
  ### silence
53
73
 
54
74
  | Field | Type | Default | Meaning |
@@ -65,7 +85,7 @@ The run starts beside the conversation. It is meant for `do` and `say` steps: it
65
85
  | `event` | `string` | required | The event's name in the agent's `events`. |
66
86
  | `after` | `Duration` | none | Park the run this long before its first step. |
67
87
  | `if` | `Pred<C, D>` | none | Judged when the event arrives, with the payload as `input`. |
68
- | `businessHours` | `boolean` | `false` | Snap the `after` wake forward. |
88
+ | `businessHours` | `boolean` | `false` | Start only in working hours: snap the start (now, or now + `after`) forward with the agent's `businessHours`. |
69
89
  | `repeat` | `Repeat` | `'always'` | |
70
90
 
71
91
  The event's `payload` is the run's `input`.
@@ -96,15 +116,15 @@ Pass a real message `id` on every message turn. Only `id` is checked against the
96
116
 
97
117
  **Dedupe key.** `${flowId}:${anchor}:${nonce}`. The nonce is the trigger key when `repeat` is `'always'` and empty otherwise. It is written to `session.claims` when the run starts, given to actions as `ctx.dedupeKey`, and returned in `started[]`. The last 50 `'always'` claims per flow and anchor are kept; `'once'` and cooldown claims are never pruned. The host may pass claims from the customer's other sessions in `turn({ claims })`; they count the same.
98
118
 
99
- **Wake for `after`.** `${runId}:start:${atMs}`, where `atMs` is the fire time in milliseconds after `businessHours` snapping. The run is returned in `started[]` at once with the outcome `code: 'awaiting-trigger'`. At the wake it enters its first step. A second event for the same flow and anchor while it is parked replaces it: the parked run ends with reason `'replaced'`.
119
+ **Wake for `after`.** `${runId}:start:${atMs}`, where `atMs` is the fire time in milliseconds after `businessHours` snapping. With `businessHours: true` and no `after`, the same wake parks a run whose event arrives outside working hours; inside them it starts at once. The run is returned in `started[]` at once with the outcome `code: 'awaiting-trigger'`. At the wake it enters its first step. A second event for the same flow and anchor while it is parked replaces it: the parked run ends with reason `'replaced'`.
100
120
 
101
121
  **Wake for silence.** `silence:${flowId}:${sessionId}:${lastAssistantAtMs}`. Armed at the end of every turn in which the assistant spoke and the customer has not written since, for every silence flow passing `if` and `repeat`; the entry's `replaces` names the previous silence wake. At fire time it is honoured only while `session.lastAssistantAt` still equals that timestamp and the customer has not written since (`code: 'silence-broken'` otherwise).
102
122
 
103
123
  ## Behaviour
104
124
 
105
125
  **How message flows are chosen.** On a message turn, the eligible flows are those with a non-empty `message` list passing `if` and `repeat`, in agent order.
106
- - One eligible flow and no run asking: it starts without being scored. The understand call is skipped altogether when there is nothing else to judge: no mention flows, no `when` branches, no unknown `'anywhere'` field.
107
- - Several: the understand call scores each from 0 to 100. With no run asking, the top flow starts if it scores 40 or more; otherwise the first eligible `message: []` flow starts, if there is one.
126
+ - No run asking: the understand call scores each eligible flow from 0 to 100, a lone one included. The top flow starts if it scores 40 or more; otherwise the first eligible `message: []` flow starts, if there is one.
127
+ - One exception: a single eligible flow, with no `message: []` flow passing and `idle: 'silent'`, starts without being scored, because a low score would leave the customer with no reply. The understand call is then skipped altogether when there is nothing else to judge: no mention flows, no `when` branches, no unknown `'anywhere'` field.
108
128
  - A run is asking: it keeps the floor unless another flow scores at least 15 above it and at least 40. Then that flow starts, or its suspended run resumes, and the asker is suspended.
109
129
  - The catch-all `message: []` is never scored. It obeys `if` and `repeat` like any trigger, so with the default `'once'` it fires once per session. When nothing starts and no run is asking, the idle speaker answers.
110
130
 
@@ -239,7 +239,7 @@ type TurnInput<C, D, E> = {
239
239
  interface TurnResult<D> {
240
240
  session: Session<D>; changed: boolean; // changed: false → save nothing
241
241
  messages: Array<{ text: string; kind: 'ai' | 'verbatim'; media?: { slug: string }; afterMs: number; key: string; runId?: string; stepId?: string }>;
242
- schedule: Array<{ key: string; at: Date; replaces?: string }>; // jobId = key; at fire: turn({ wake: key })
242
+ schedule: Array<{ key: string; at: Date; replaces?: string }>; // job id = the key, encoded (BullMQ refuses ':'); at fire: turn({ wake: key })
243
243
  outcomes: StepOutcome[];
244
244
  started: Array<{ runId: string; flowId: string; anchor: string; dedupeKey: string }>;
245
245
  ended: Array<Run & { reason: 'end' | 'flow' | 'reset' | 'skipped' | 'failed' | 'replaced' }>;
@@ -265,7 +265,7 @@ Eight phases, one order for every input; only 3 and 6 spend LLM calls. `turn()`
265
265
  - `wake` starting with `silence:`: honored only when its `lastAssistantAtMs` equals `session.lastAssistantAt` and the lead has not written since (`lastUserAt`, and the anchor's `lastInboundAt` for lead-anchored flows, both `< lastAssistantAt`); it then goes through the start order below with `trigger.kind: 'silence'`. Otherwise `ignorado: silêncio quebrado`, `changed: false`. Any other `wake`: the run whose `waiting.key` equals it, else `ignorado: wake antigo`. A timer `wait` with `else` whose lead wrote after `waiting.setAt` takes `else` — the reply beat the job.
266
266
  - `event` / `start`: flows with a matching trigger start, in this order: `if` (payload as `input`) → `repeat` via claims (cooldown = interval check on `claims[key].at`) → `hop < 5` else `pulado: limite de encadeamento` → one active run per (flow, anchor) against `session.runs` and `claims.active`: a live run still parked on its own `after` timer is **replaced** (`ended.reason: 'replaced'`, its job self-skips), any other live run → `pulado: já em andamento` → `after` parks the new run.
267
267
  - Every run about to move re-checks `while` with fresh context; false → ends `pulado: premissa mudou`. Silence-triggered runs add the premise "no lead message since the run started" → `pulado: lead escreveu nesse meio-tempo`.
268
- 3. **Understand** (≤1 call, `schemaName: 'understand'`). Only for `message` and inbound events with text; skipped under `silenced` unless `understand: true`, and when nothing is AI-conditioned. Candidates: the floor holder's flow (always, whatever its trigger), `message` flows passing `if` + `repeat`, `mention` flows with non-empty `mention` passing `if` + `repeat`. Envelope, every property required and nullable: `{ flows: { id: 0-100 }, mentions: { id: boolean }, extract: { id: {...} }, branches: { 'runId/stepId/i': boolean }, fields: { every pending field with extract 'anywhere' } }`. Shortcuts: exactly one eligible `message` flow and no floor → it starts, no scoring; zero candidates and zero pending fields → zero calls (S9).
268
+ 3. **Understand** (≤1 call, `schemaName: 'understand'`). Only for `message` and inbound events with text; skipped under `silenced` unless `understand: true`, and when nothing is AI-conditioned. Candidates: the floor holder's flow (always, whatever its trigger), `message` flows passing `if` + `repeat`, `mention` flows with non-empty `mention` passing `if` + `repeat`. Envelope, every property required and nullable: `{ flows: { id: 0-100 }, mentions: { id: boolean }, extract: { id: {...} }, branches: { 'runId/stepId/i': boolean }, fields: { every pending field with extract 'anywhere' } }`. Shortcuts: exactly one eligible `message` flow, no floor, no catch-all passing and `idle: 'silent'` → it starts, no scoring (with a catch-all or the idle speaker, a low score has somewhere to go, so it is scored); zero candidates and zero pending fields → zero calls (S9).
269
269
  4. **Decide** (code). Runs starting this turn apply `clearOnStart` first. Extracted values are checked: unknown keys dropped (`ignorado: campo desconhecido`), strings coerced to number/boolean, enum membership enforced, otherwise `campo descartado: valor fora da lista`; then written to `session.data` (one `known`: not `undefined | null | ''`). Mention runs start in flow order (a trigger `if` with `extract` sees `input` now); `mention: []` code-only detectors start here without the call. The first true branch of the asking step takes its `then`. Routing: if a run took or resumed the floor in Ingest, routing is skipped (scores recorded, not applied). Otherwise the floor (any run `running`/`asking`, not `waiting`) stays unless another flow scores ≥ current + 15 and ≥ 40; no floor → best ≥ 40 starts (a `suspended` run of that flow resumes instead), then a `message: []` catch-all, else the `idle` speaker.
270
270
  5. **Run** (code). If no run is `asking`, the most recently `suspended` returns to `asking`. The Runner advances every run that can move, oldest first, through `advance()`: `do` runs the handler with `key = ${runId}:${stepId}:${visit}` (`run.visits[stepId]` increments on entry; at-least-once, before the save); `failed` writes a `failed` outcome and continues to `then` unless `onFail`; `skipped` continues; `defer` re-parks under a fresh wake key; `spoke: true` makes it the turn's speaker. `say` appends a message (`once` → claim `${flowId}:${stepId}:${sessionId}`). `wait ≤ 10s` carries as `afterMs` to the next message emitted this turn, or schedules a real wake if none follows; longer waits park with `waiting` and a `schedule` entry, snapped by `businessHours`. `if` picks `then`/`else`. A talk step whose fields are all known is skipped (0 calls); otherwise it is queued for phase 6 and the current asker becomes `suspended` (a run started this turn by routing never suspends one that took the floor in Ingest). Flow missing from the agent → `pulado: fluxo desativado ou removido`; step missing → `pulado: passo removido`. Caps: 50 steps per run per turn → `falhou: laço de passos`; hop 5.
271
271
  6. **Speak** (≤1 call + tool rounds, `schemaName: 'speak'`). One talk step speaks. Talk step = `prompt`, `collect`, `say`. Rules: a `say` or `spoke: true` emitted this turn by a run **other than** the floor holder, in reply to a user message, silences the floor's talk (`pulado: outra resposta já saiu`; the run stays `asking`) — a run's own `say` never silences its own talk. Under `silenced` no message-producing step runs and no run advances past one: an `asking` run stays `asking`; a run whose talk step was reached by a wake, event or start ends `silenciado: <reason>` (an earlier `if: { silenced: true }` routes around it); `do` steps still run; zero calls. Prompt = identity + flow + step guideline + this step's pending fields with their `ask` (all of them; the step prompt says how many to ask) + known fields as facts + tools + instructions + history. A wake states *não há mensagem nova do cliente; você fala primeiro*. Envelope `{ message, ...pending fields of this step }`, all required and nullable, shallow-merged last-wins across tool rounds. Provider failure: in phase 3 the turn throws `ProviderError` (nothing ran, nothing saved; the host retries the input); in phase 6 the turn returns with the talk step re-parked under `${runId}:${stepId}:${visit}:retry:${atMs}` (+1m, +5m, +15m), outcome `deferred: IA indisponível`, session saved, phase 5 effects not repeated.
@@ -304,7 +304,7 @@ interface Store<D> { load(id: string): Promise<Session<D> | null>; save(session:
304
304
 
305
305
  **Keys.** Trigger key: `message`/`mention` → the input `id` (playground: `at`, replays not idempotent); `silence` → `lastAssistantAtMs`; `event`/`start` → the host key; `flow` → `${parentRunId}:${stepId}:${visit}`. Wake key: `${runId}:${stepId}:${atMs}`. Dedupe key `${flowId}:${anchor}:${nonce}`, nonce `''` for `once` and cooldown (blocked while `now - claims[key].at < cooldown`, else overwritten), the trigger key for `always` (last 50 kept).
306
306
 
307
- **Host contract:** (a) one `turn` per session at a time; a wake queues behind a debounced inbound not yet turned. (b) On every input: fresh `context`, `history`, `anchors` (with the lead's `lastInboundAt`), `claims` for non-`always` flows, `silenced` for every "cannot speak now" reason (ownership, Pausa, closed 24h window, quota, `sem conversa`); inbound messages carry the channel `id` and `at`. (c) `changed: false` → nothing. Else one transaction: `store.save`, `started[].dedupeKey` into a unique index, `started`/`ended` into a partial unique index `(flowId, anchor) WHERE live`, `outcomes`/`ended`/`skipped` into the `flowRuns` mirror, `messages[]` and `schedule[]` into the outbox. Conflict or unique violation → discard, replay. (d) Drain the outbox: send honoring `afterMs`, record a refused send against `key` in the mirror (`enviada`/`recusada` beside the framework's `gerada`); enqueue wakes with `jobId = key`, `queue.remove(replaces)` best-effort. (e) At fire: `turn({ wake, silenced })`. (f) Call `turn` for every inbound even while a human owns the lead. (g) `businessHours(at)` snaps, never clamps. (h) Lead-level events go to the lead's latest open conversation; none → session `lead:<id>` with `silenced: 'sem conversa'`. (i) `ProviderError` → retry the input with backoff.
307
+ **Host contract:** (a) one `turn` per session at a time; a wake queues behind a debounced inbound not yet turned. (b) On every input: fresh `context`, `history`, `anchors` (with the lead's `lastInboundAt`), `claims` for non-`always` flows, `silenced` for every "cannot speak now" reason (ownership, Pausa, closed 24h window, quota, `sem conversa`); inbound messages carry the channel `id` and `at`. (c) `changed: false` → nothing. Else one transaction: `store.save`, `started[].dedupeKey` into a unique index, `started`/`ended` into a partial unique index `(flowId, anchor) WHERE live`, `outcomes`/`ended`/`skipped` into the `flowRuns` mirror, `messages[]` and `schedule[]` into the outbox. Conflict or unique violation → discard, replay. (d) Drain the outbox: send honoring `afterMs`, record a refused send against `key` in the mirror (`enviada`/`recusada` beside the framework's `gerada`); enqueue wakes under a job id made from the key (encoded: BullMQ refuses a `:` in a custom id), remove `replaces` the same way, best-effort. (e) At fire: `turn({ wake, silenced })`. (f) Call `turn` for every inbound even while a human owns the lead. (g) `businessHours(at)` snaps, never clamps. (h) Lead-level events go to the lead's latest open conversation; none → session `lead:<id>` with `silenced: 'sem conversa'`. (i) `ProviderError` → retry the input with backoff.
308
308
 
309
309
  ```ts
310
310
  const clock = fakeClock('2026-09-20T10:00Z');
@@ -420,7 +420,7 @@ const criarFluxo = tool({
420
420
 
421
421
  **S8 Scheduling.** `on: [{ message: ['quer marcar', 'quer remarcar'], repeat: 'always' }]`, `clearOnStart: ['data_preferida', 'horario_preferido', 'agenda_event_id', 'confirmado']` (applied before "sexta às 15h" lands), collect with agenda tools, `{ collect: ['confirmado'], ask: { confirmado: 'Confirme a data e o horário em uma frase.' } }`, `do book`, last step `then: { flow: 'pos-agendamento' }`.
422
422
 
423
- **S9 Config assistant.** Zero flows, zero fields → understand skipped → one speak call with tools. Interview mode: one `message` flow → single-flow shortcut, no scoring; collect steps skip when known.
423
+ **S9 Config assistant.** Zero flows, zero fields → understand skipped → one speak call with tools. Interview mode: one `message` flow and `idle: 'silent'` → single-flow shortcut, no scoring; collect steps skip when known.
424
424
 
425
425
  **S10.** §8.
426
426
 
@@ -458,7 +458,7 @@ async function runTurn(sessionId: string, input: TurnInputBody) {
458
458
  await outbox.put(tx, r.messages, r.schedule); // wakes ride in the same transaction
459
459
  });
460
460
  } catch (e) { if (e instanceof SessionConflictError || isUniqueViolation(e)) continue; throw e; }
461
- await outbox.drain(sessionId); // send honoring afterMs, enqueue wakes jobId = key
461
+ await outbox.drain(sessionId); // send honoring afterMs, enqueue wakes under the encoded key
462
462
  return;
463
463
  }
464
464
  }
@@ -174,7 +174,7 @@ const agent = f.agent({
174
174
 
175
175
  `parameters` use the same `type`, `enum` and `description` as fields (plus `optional: true`), and `run` receives them already typed: `mensagem` is a `string` above, nothing to check.
176
176
 
177
- `with` fills the parameters. `{{data.x}}`, `{{context.x}}` and `{{input.x}}` are replaced before the action runs. A placeholder whose value is unknown stays as written, so a skipped `tamanho` reaches the seller as `{{data.tamanho}}`. Word the message without it, or fork first with an `if` step on `{ known: ["tamanho"] }`. A `do` step never talks to the model: zero calls.
177
+ `with` fills the parameters. `{{data.x}}`, `{{context.x}}` and `{{input.x}}` are replaced before the action runs. A placeholder whose value is unknown stays as written, so a skipped `tamanho` reaches the seller as `{{data.tamanho}}`. One the host filled with an empty string drops out instead. Word the message without it, or fork first with an `if` step on `{ known: ["tamanho"] }`. A `do` step never talks to the model: zero calls.
178
178
 
179
179
  The name and the parameters are checked when the agent is built. An unknown action, or a `with` missing a required parameter, throws `FlowConfigurationError` at startup with the step id and the fix.
180
180
 
@@ -170,7 +170,7 @@ A `wait` longer than 10 seconds, a `silence` trigger or an `event` trigger with
170
170
  { key: "triagem#wamid.HBgL:w1:1790244000000", at: new Date("2026-09-24T10:00:00.000Z") }
171
171
  ```
172
172
 
173
- A `silence` trigger's wake also carries `replaces`: the earlier silence wake it supersedes. Put the entry in your queue with `jobId = key` and the session id in the payload. When `replaces` is set, remove that job; it is best effort, a stale wake is harmless. When the job fires:
173
+ A `silence` trigger's wake also carries `replaces`: the earlier silence wake it supersedes. Put the entry in your queue with the key and the session id in the payload, under a job id made from the key: every key contains `:`, and BullMQ refuses a custom job id with a `:` in it unless it splits into exactly three parts, so encode it as `encodeURIComponent(key)`. When `replaces` is set, remove the job whose id is `encodeURIComponent(replaces)`; it is best effort, a stale wake is harmless. When the job fires:
174
174
 
175
175
  ```ts fragment
176
176
  await handle({ sessionId: job.data.sessionId, wake: job.data.key });
@@ -178,6 +178,21 @@ await handle({ sessionId: job.data.sessionId, wake: job.data.key });
178
178
 
179
179
  Only the run still waiting on that exact key honours the wake. Anything else returns `changed: false` with one line in `r.outcomes`: `code: 'stale-wake'` for a wait that a reply already resolved, `code: 'silence-broken'` when the customer wrote after the silence wake was set, `code: 'no-session'` when there is no session. So you do not have to cancel jobs: fire every one and the framework drops the stale ones.
180
180
 
181
+ ### When the queue loses its jobs
182
+
183
+ A flushed Redis, or sessions lifted from 3.x by `migrateSession`, leave conversations waiting on wakes nobody will fire. `agent.pendingWakes({ session, context })` returns every wake a saved session waits on: each parked run's, and each silence flow's counted from `lastAssistantAt`, with the trigger's `if` and `repeat` judged on the `context` and `claims` you pass, as a turn would. It changes nothing and spends no model call. Enqueue what it returns as you would a turn's `schedule[]`:
184
+
185
+ ```ts fragment
186
+ for (const wake of agent.pendingWakes({ session, context })) {
187
+ await queue.add("wake", { sessionId: session.id, key: wake.key }, {
188
+ jobId: encodeURIComponent(wake.key),
189
+ delay: Math.max(0, wake.at.getTime() - Date.now()),
190
+ });
191
+ }
192
+ ```
193
+
194
+ Running it twice is safe: BullMQ ignores a job whose id is already queued, and a wake that fires twice is dropped the second time as stale.
195
+
181
196
  `fakeClock` and `MemoryScheduler` are the test doubles for this: [Testing](../guides/testing.md) plays a two-day follow-up in one test.
182
197
 
183
198
  ## On every input
@@ -117,9 +117,10 @@ const agent = f.agent({
117
117
  });
118
118
 
119
119
  // ─── The host loop ─────────────────────────────────────────────────────────
120
- // Real hosts persist `session`, enqueue each `schedule[]` entry with
121
- // `jobId = key`, and call `turn({ wake: key })` when it fires. Here we keep
122
- // them in memory and jump the clock.
120
+ // Real hosts persist `session`, enqueue each `schedule[]` entry under the job
121
+ // id `encodeURIComponent(key)` (BullMQ refuses a `:` in a custom id), and call
122
+ // `turn({ wake: key })` when it fires. Here we keep them in memory and jump
123
+ // the clock.
123
124
 
124
125
  const context: Ctx = { lead: { id: "456", nome: "Ana", etapa: "proposta", dono: "ia", tags: [] } };
125
126
  const timers: ScheduleEntry[] = [];
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@falai/agent",
3
- "version": "4.0.0-alpha.1",
3
+ "packageManager": "bun@1.4.2",
4
+ "version": "4.0.0-alpha.11",
4
5
  "description": "Conversational state engine for TypeScript where the AI understands, but the code is in control",
5
6
  "type": "module",
6
7
  "main": "./dist/cjs/index.js",
@@ -59,7 +60,8 @@
59
60
  "release:alpha": "bun publish --tag alpha",
60
61
  "release": "bun publish",
61
62
  "test": "bun test tests/*.test.ts tests/scenarios/*.test.ts",
62
- "eval:live": "bun run scripts/eval/live.ts"
63
+ "eval:live": "bun run scripts/eval/live.ts",
64
+ "eval:exclusions": "bun run scripts/eval/exclusions.ts"
63
65
  },
64
66
  "keywords": [
65
67
  "ai",
@@ -95,7 +97,7 @@
95
97
  "typescript-eslint": "^8.18.2"
96
98
  },
97
99
  "dependencies": {
98
- "@providerkit/core": "^0.11.0",
100
+ "@providerkit/core": "^0.11.1",
99
101
  "loglevel": "^1.9.2"
100
102
  },
101
103
  "peerDependencies": {
package/src/core/Agent.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * provider call apiece, and Runner settles what they returned.
8
8
  */
9
9
 
10
- import type { AgentOptions, TurnInput, TurnResult, TurnStreamChunk } from "../types/agent.js";
10
+ import type { AgentOptions, PendingWakesInput, ScheduleEntry, TurnInput, TurnResult, TurnStreamChunk } from "../types/agent.js";
11
11
  import type { TokenUsage } from "../types/ai.js";
12
12
  import type { CompactionOptions } from "../types/compaction.js";
13
13
  import { FlowConfigurationError } from "../types/errors.js";
@@ -56,6 +56,16 @@ export class Agent<C = unknown, D = unknown> {
56
56
  yield { done: true, result: this.runner.finish(turn) };
57
57
  }
58
58
 
59
+ /**
60
+ * Every wake a saved session is waiting on, as its turns scheduled them: each parked run's,
61
+ * and each silence flow's since the assistant last spoke. For a queue that lost its jobs, and
62
+ * for sessions no turn has armed yet, such as blobs lifted by `migrateSession`. It changes
63
+ * nothing and spends no call: enqueue the entries as you would a turn's `schedule[]`.
64
+ */
65
+ pendingWakes(input: PendingWakesInput<C, D>): ScheduleEntry[] {
66
+ return this.runner.pendingWakes(input);
67
+ }
68
+
59
69
  /** Load, Ingest, Understand, Decide and Run: everything before the one speaker is known. */
60
70
  private async open(input: TurnInput<C, D>): Promise<{ turn: Turn<C, D>; talk: TalkRequest<C, D> | IdleRequest<C, D> | null }> {
61
71
  const { runner } = this;
@@ -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;
@@ -248,6 +251,7 @@ interface LooseStep {
248
251
  ask?: Partial<Record<string, string>>;
249
252
  branches?: LooseBranch[];
250
253
  instructions?: LooseInstruction[];
254
+ say?: Template;
251
255
  do?: string;
252
256
  with?: Record<string, unknown>;
253
257
  wait?: Duration | { event: string; upTo?: Duration };
@@ -268,6 +272,12 @@ const BUILT_IN_CONDITIONS = ["equals", "known", "silenced"];
268
272
 
269
273
  const DURATION_HINT = 'Write a number and a unit: "30s", "5m", "24h" or "3d".';
270
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
+
271
281
  /**
272
282
  * Check a flow, typed or as a spec, against the agent's registries. Throws
273
283
  * `FlowConfigurationError` on the first problem that would break at runtime;
@@ -400,11 +410,8 @@ export function validateFlow<C = unknown, D = LooseData>(
400
410
  continue;
401
411
  }
402
412
  if (!matchesParam(def, value)) {
403
- throw problem(
404
- at,
405
- `parameter "${param}" of action "${name}" must be ${describeDef(def)}, got ${describe(value)}`,
406
- "Values are not coerced; write the right type.",
407
- );
413
+ const { expected, got, fix } = mismatch(def, value);
414
+ throw problem(at, `parameter "${param}" of action "${name}" must be ${expected}, got ${got}`, fix);
408
415
  }
409
416
  }
410
417
  for (const param of Object.keys(given)) {
@@ -421,6 +428,18 @@ export function validateFlow<C = unknown, D = LooseData>(
421
428
 
422
429
  flow.on?.forEach((trigger, i) => {
423
430
  const at = `${flowAt}, trigger #${i + 1}`;
431
+ // A trigger that names no kind can never fire, and nothing downstream says
432
+ // so: the Runner simply never finds it eligible and the flow looks broken
433
+ // for some other reason. `{ kind: 'message', when: [...] }` — the v3 shape —
434
+ // lands here, and so does a typo in the one key that matters.
435
+ if (!TRIGGER_KINDS.some((key) => trigger[key] !== undefined)) {
436
+ throw problem(
437
+ at,
438
+ "names no trigger kind",
439
+ `A trigger is one of ${TRIGGER_KINDS.map((key) => `\`${key}\``).join(", ")}. ` +
440
+ "A flow the host starts itself has no `on` at all.",
441
+ );
442
+ }
424
443
  if (trigger.event !== undefined && !own(events, trigger.event)) {
425
444
  throw problem(at, `unknown event "${trigger.event}"`, "Register it in events or fix the name.");
426
445
  }
@@ -428,10 +447,36 @@ export function validateFlow<C = unknown, D = LooseData>(
428
447
  duration(trigger.after, at, "after");
429
448
  if (typeof trigger.repeat === "object") duration(trigger.repeat.cooldown, at, "repeat.cooldown");
430
449
  pred(trigger.if, at, "if");
450
+ // Phrases opening with `!` rule the trigger out; a list of nothing but
451
+ // those can never fire, so the flow is dead and nothing would say so.
452
+ // `message: []` is the deliberate catch-all and stays legal.
453
+ for (const key of ["message", "mention"] as const) {
454
+ const phrases: string[] | undefined = trigger[key];
455
+ if (!phrases?.length) continue;
456
+ if (splitPhrases(phrases).counts.length > 0) continue;
457
+ throw problem(
458
+ at,
459
+ `every ${key} phrase starts with "!", so nothing can ever match it`,
460
+ `A "!" phrase rules the trigger out. Add at least one plain phrase saying when it should fire${
461
+ key === "message" ? ", or use an empty list for a catch-all" : ""
462
+ }.`,
463
+ );
464
+ }
431
465
  });
432
466
 
433
467
  flow.steps.forEach((step, i) => {
434
468
  const at = `${flowAt}, step "${step.id}"`;
469
+ // Same reasoning as the trigger above: a step that does none of the five
470
+ // things is a step the run walks straight past, silently. `kind` alone is
471
+ // not enough — the spec form drops it and keeps the body, so what counts
472
+ // is whether the body says what to do.
473
+ if (!STEP_DOES.some((key) => step[key] !== undefined)) {
474
+ throw problem(
475
+ at,
476
+ "does nothing",
477
+ "A step talks (`prompt` / `collect`), says (`say`), acts (`do`), waits (`wait`) or forks (`if`).",
478
+ );
479
+ }
435
480
  const thenTo = edge(i, step.then, at, "then");
436
481
  edge(i, step.else, at, "else");
437
482
 
@@ -506,12 +551,33 @@ function matches(def: ScalarDef, value: unknown): boolean {
506
551
  return typeof value !== "boolean" && def.enum.includes(value);
507
552
  }
508
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
+
509
570
  function describe(value: unknown): string {
510
571
  return Array.isArray(value) ? "list" : value === null ? "null" : typeof value;
511
572
  }
512
573
 
513
574
  function describeDef(def: ParamDef): string {
514
- 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";
515
581
  }
516
582
 
517
583
  // ── flowSpecSchema ──────────────────────────────────────────────────────
@@ -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
  }