@falai/agent 4.0.0-alpha.1 → 4.0.0-alpha.10
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.
- package/dist/cjs/core/Agent.d.ts +8 -1
- package/dist/cjs/core/Agent.d.ts.map +1 -1
- package/dist/cjs/core/Agent.js +9 -0
- package/dist/cjs/core/Agent.js.map +1 -1
- package/dist/cjs/core/FlowSpec.d.ts.map +1 -1
- package/dist/cjs/core/FlowSpec.js +53 -2
- package/dist/cjs/core/FlowSpec.js.map +1 -1
- package/dist/cjs/core/Prompt.d.ts.map +1 -1
- package/dist/cjs/core/Prompt.js +7 -1
- package/dist/cjs/core/Prompt.js.map +1 -1
- package/dist/cjs/core/Runner.d.ts +14 -3
- package/dist/cjs/core/Runner.d.ts.map +1 -1
- package/dist/cjs/core/Runner.js +47 -20
- package/dist/cjs/core/Runner.js.map +1 -1
- package/dist/cjs/core/Speak.d.ts.map +1 -1
- package/dist/cjs/core/Speak.js +11 -2
- package/dist/cjs/core/Speak.js.map +1 -1
- package/dist/cjs/core/Understand.d.ts.map +1 -1
- package/dist/cjs/core/Understand.js +17 -13
- package/dist/cjs/core/Understand.js.map +1 -1
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/providers/ProviderAdapter.d.ts +10 -5
- package/dist/cjs/providers/ProviderAdapter.d.ts.map +1 -1
- package/dist/cjs/providers/ProviderAdapter.js.map +1 -1
- package/dist/cjs/providers/ZaiProvider.d.ts +6 -4
- package/dist/cjs/providers/ZaiProvider.d.ts.map +1 -1
- package/dist/cjs/providers/ZaiProvider.js +6 -4
- package/dist/cjs/providers/ZaiProvider.js.map +1 -1
- package/dist/cjs/types/agent.d.ts +12 -2
- package/dist/cjs/types/agent.d.ts.map +1 -1
- package/dist/cjs/types/index.d.ts +1 -1
- package/dist/cjs/types/index.d.ts.map +1 -1
- package/dist/cjs/types/index.js.map +1 -1
- package/dist/cjs/utils/phrases.d.ts +25 -0
- package/dist/cjs/utils/phrases.d.ts.map +1 -0
- package/dist/cjs/utils/phrases.js +38 -0
- package/dist/cjs/utils/phrases.js.map +1 -0
- package/dist/cjs/utils/template.d.ts +8 -0
- package/dist/cjs/utils/template.d.ts.map +1 -1
- package/dist/cjs/utils/template.js +40 -3
- package/dist/cjs/utils/template.js.map +1 -1
- package/dist/core/Agent.d.ts +8 -1
- package/dist/core/Agent.d.ts.map +1 -1
- package/dist/core/Agent.js +9 -0
- package/dist/core/Agent.js.map +1 -1
- package/dist/core/FlowSpec.d.ts.map +1 -1
- package/dist/core/FlowSpec.js +53 -2
- package/dist/core/FlowSpec.js.map +1 -1
- package/dist/core/Prompt.d.ts.map +1 -1
- package/dist/core/Prompt.js +7 -1
- package/dist/core/Prompt.js.map +1 -1
- package/dist/core/Runner.d.ts +14 -3
- package/dist/core/Runner.d.ts.map +1 -1
- package/dist/core/Runner.js +47 -20
- package/dist/core/Runner.js.map +1 -1
- package/dist/core/Speak.d.ts.map +1 -1
- package/dist/core/Speak.js +11 -2
- package/dist/core/Speak.js.map +1 -1
- package/dist/core/Understand.d.ts.map +1 -1
- package/dist/core/Understand.js +17 -13
- package/dist/core/Understand.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/providers/ProviderAdapter.d.ts +10 -5
- package/dist/providers/ProviderAdapter.d.ts.map +1 -1
- package/dist/providers/ProviderAdapter.js.map +1 -1
- package/dist/providers/ZaiProvider.d.ts +6 -4
- package/dist/providers/ZaiProvider.d.ts.map +1 -1
- package/dist/providers/ZaiProvider.js +6 -4
- package/dist/providers/ZaiProvider.js.map +1 -1
- package/dist/types/agent.d.ts +12 -2
- package/dist/types/agent.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/utils/phrases.d.ts +25 -0
- package/dist/utils/phrases.d.ts.map +1 -0
- package/dist/utils/phrases.js +35 -0
- package/dist/utils/phrases.js.map +1 -0
- package/dist/utils/template.d.ts +8 -0
- package/dist/utils/template.d.ts.map +1 -1
- package/dist/utils/template.js +40 -3
- package/dist/utils/template.js.map +1 -1
- package/docs/concepts/architecture.md +2 -2
- package/docs/concepts/pipeline.md +4 -4
- package/docs/guides/actions-and-events.md +1 -1
- package/docs/guides/conditions.md +1 -1
- package/docs/guides/error-handling.md +2 -0
- package/docs/guides/persistence.md +1 -1
- package/docs/guides/testing.md +1 -1
- package/docs/guides/triggers.md +3 -3
- package/docs/migration/v3-to-v4.md +12 -7
- package/docs/reference/actions-events-conditions.md +1 -1
- package/docs/reference/agent.md +8 -4
- package/docs/reference/flow-spec.md +1 -1
- package/docs/reference/providers.md +2 -2
- package/docs/reference/trigger.md +22 -2
- package/docs/rfc/v4-one-flow.md +5 -5
- package/docs/start/04-add-tools.md +1 -1
- package/docs/start/05-go-to-production.md +16 -1
- package/examples/06-triggers-and-waits.ts +4 -3
- package/package.json +5 -3
- package/src/core/Agent.ts +11 -1
- package/src/core/FlowSpec.ts +72 -6
- package/src/core/Prompt.ts +7 -1
- package/src/core/Runner.ts +54 -21
- package/src/core/Speak.ts +11 -2
- package/src/core/Understand.ts +12 -11
- package/src/index.ts +1 -0
- package/src/providers/ProviderAdapter.ts +10 -5
- package/src/providers/ZaiProvider.ts +6 -4
- package/src/types/agent.ts +13 -2
- package/src/types/index.ts +1 -0
- package/src/utils/phrases.ts +40 -0
- package/src/utils/template.ts +39 -3
|
@@ -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.
|
|
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"'`.
|
package/docs/reference/agent.md
CHANGED
|
@@ -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
|
|
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 `
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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 |
|
|
@@ -103,8 +123,8 @@ Pass a real message `id` on every message turn. Only `id` is checked against the
|
|
|
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
|
-
-
|
|
107
|
-
-
|
|
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
|
|
package/docs/rfc/v4-one-flow.md
CHANGED
|
@@ -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 }>; //
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
121
|
-
// `
|
|
122
|
-
// them in memory and jump
|
|
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
|
-
"
|
|
3
|
+
"packageManager": "bun@1.4.2",
|
|
4
|
+
"version": "4.0.0-alpha.10",
|
|
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.
|
|
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;
|
package/src/core/FlowSpec.ts
CHANGED
|
@@ -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
|
-
|
|
404
|
-
|
|
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}` :
|
|
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 ──────────────────────────────────────────────────────
|
package/src/core/Prompt.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/core/Runner.ts
CHANGED
|
@@ -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:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
this.
|
|
213
|
-
|
|
214
|
-
|
|
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 {
|
|
@@ -508,8 +526,7 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
508
526
|
const floorRun = this.floorRun(turn);
|
|
509
527
|
const floorFlow = floorRun && this.flows.get(floorRun.flowId);
|
|
510
528
|
const eligible = this.eligibleMessageFlows(turn);
|
|
511
|
-
|
|
512
|
-
const messageFlows = !floorRun && eligible.length === 1 ? [] : eligible;
|
|
529
|
+
const messageFlows = !floorRun && this.startsUnscored(turn, eligible) ? [] : eligible;
|
|
513
530
|
const mentionFlows = this.mentionFlows()
|
|
514
531
|
.filter(({ flow, trigger }) => trigger.mention.length > 0 && this.repeatAllows(turn, flow, trigger, turn.triggerKey))
|
|
515
532
|
.map(({ flow }) => flow);
|
|
@@ -562,6 +579,14 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
562
579
|
return this.messageFlows(turn, (list) => list.length > 0);
|
|
563
580
|
}
|
|
564
581
|
|
|
582
|
+
/**
|
|
583
|
+
* The lone eligible flow starts without a score only when a low score would have nowhere
|
|
584
|
+
* else to send the message: no catch-all passes and the idle speaker is silent (S9).
|
|
585
|
+
*/
|
|
586
|
+
private startsUnscored(turn: Turn<C, D>, eligible: Flow<C, D>[]): boolean {
|
|
587
|
+
return eligible.length === 1 && this.options.idle === "silent" && this.messageFlows(turn, (list) => list.length === 0).length === 0;
|
|
588
|
+
}
|
|
589
|
+
|
|
565
590
|
private messageFlows(turn: Turn<C, D>, accept: (list: string[]) => boolean): Flow<C, D>[] {
|
|
566
591
|
const out: Flow<C, D>[] = [];
|
|
567
592
|
for (const flow of this.flows.values()) {
|
|
@@ -605,7 +630,7 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
605
630
|
const current = scores[asker.flowId] ?? 0;
|
|
606
631
|
const top = best(eligible.filter((f) => f.id !== asker.flowId));
|
|
607
632
|
if (top && top.score >= current + ROUTE_STICKY && top.score >= ROUTE_MIN) route = top.flow;
|
|
608
|
-
} else if (
|
|
633
|
+
} else if (this.startsUnscored(turn, eligible)) {
|
|
609
634
|
route = eligible[0];
|
|
610
635
|
} else {
|
|
611
636
|
const top = best(eligible);
|
|
@@ -1139,14 +1164,21 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
1139
1164
|
return new Date(reset !== undefined && reset > ladder ? reset : ladder);
|
|
1140
1165
|
}
|
|
1141
1166
|
|
|
1142
|
-
/** The assistant spoke
|
|
1167
|
+
/** The assistant spoke this turn: its silence wakes replace the ones its last words set. */
|
|
1143
1168
|
private armSilence(turn: Turn<C, D>): void {
|
|
1169
|
+
const previous = turn.original?.lastAssistantAt;
|
|
1170
|
+
if (turn.session.lastAssistantAt === previous) return;
|
|
1171
|
+
turn.schedule.push(...this.silenceWakes(turn, previous));
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** The assistant spoke last: every silence flow passing `if` and `repeat` gets a wake. */
|
|
1175
|
+
private silenceWakes(turn: Turn<C, D>, previous?: string): ScheduleEntry[] {
|
|
1144
1176
|
const { session } = turn;
|
|
1145
1177
|
const { lastAssistantAt, lastUserAt } = session;
|
|
1146
|
-
if (!lastAssistantAt
|
|
1147
|
-
if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt)) return;
|
|
1178
|
+
if (!lastAssistantAt) return [];
|
|
1179
|
+
if (lastUserAt && Date.parse(lastUserAt) > Date.parse(lastAssistantAt)) return [];
|
|
1148
1180
|
const ms = Date.parse(lastAssistantAt);
|
|
1149
|
-
const
|
|
1181
|
+
const wakes: ScheduleEntry[] = [];
|
|
1150
1182
|
for (const flow of this.flows.values()) {
|
|
1151
1183
|
const trigger = (flow.on ?? []).find((t): t is SilenceTrigger<C, D> => "silence" in t);
|
|
1152
1184
|
if (!trigger) continue;
|
|
@@ -1154,12 +1186,13 @@ export class Runner<C = unknown, D = unknown> {
|
|
|
1154
1186
|
if (trigger.if && !this.holds(trigger.if, turn, this.draftRun(turn, flow, "silence", key))) continue;
|
|
1155
1187
|
if (!this.repeatAllows(turn, flow, trigger, key)) continue;
|
|
1156
1188
|
const at = this.snap(turn, new Date(ms + parseDuration(trigger.silence)), trigger.businessHours);
|
|
1157
|
-
|
|
1189
|
+
wakes.push({
|
|
1158
1190
|
key: `silence:${flow.id}:${session.id}:${ms}`,
|
|
1159
1191
|
at,
|
|
1160
1192
|
...(previous ? { replaces: `silence:${flow.id}:${session.id}:${Date.parse(previous)}` } : {}),
|
|
1161
1193
|
});
|
|
1162
1194
|
}
|
|
1195
|
+
return wakes;
|
|
1163
1196
|
}
|
|
1164
1197
|
|
|
1165
1198
|
// ── Return (design §4.8) ──────────────────────────────────────────────
|