@falai/agent 4.0.0-alpha.14 → 4.0.0-alpha.15

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 (245) hide show
  1. package/README.md +5 -3
  2. package/dist/cjs/core/Agent.js +9 -0
  3. package/dist/cjs/core/Agent.js.map +1 -1
  4. package/dist/cjs/core/CompactionEngine.d.ts.map +1 -1
  5. package/dist/cjs/core/CompactionEngine.js +8 -3
  6. package/dist/cjs/core/CompactionEngine.js.map +1 -1
  7. package/dist/cjs/core/FlowSpec.d.ts +19 -2
  8. package/dist/cjs/core/FlowSpec.d.ts.map +1 -1
  9. package/dist/cjs/core/FlowSpec.js +256 -57
  10. package/dist/cjs/core/FlowSpec.js.map +1 -1
  11. package/dist/cjs/core/Prompt.d.ts +16 -0
  12. package/dist/cjs/core/Prompt.d.ts.map +1 -1
  13. package/dist/cjs/core/Prompt.js +39 -0
  14. package/dist/cjs/core/Prompt.js.map +1 -1
  15. package/dist/cjs/core/Runner.d.ts +2 -0
  16. package/dist/cjs/core/Runner.d.ts.map +1 -1
  17. package/dist/cjs/core/Runner.js +26 -15
  18. package/dist/cjs/core/Runner.js.map +1 -1
  19. package/dist/cjs/core/Speak.d.ts.map +1 -1
  20. package/dist/cjs/core/Speak.js +22 -16
  21. package/dist/cjs/core/Speak.js.map +1 -1
  22. package/dist/cjs/core/Understand.d.ts +4 -3
  23. package/dist/cjs/core/Understand.d.ts.map +1 -1
  24. package/dist/cjs/core/Understand.js +5 -38
  25. package/dist/cjs/core/Understand.js.map +1 -1
  26. package/dist/cjs/core/contracts.d.ts +5 -5
  27. package/dist/cjs/core/contracts.d.ts.map +1 -1
  28. package/dist/cjs/index.d.ts +1 -1
  29. package/dist/cjs/index.d.ts.map +1 -1
  30. package/dist/cjs/index.js.map +1 -1
  31. package/dist/cjs/persistence/OpenSearchStore.d.ts +2 -1
  32. package/dist/cjs/persistence/OpenSearchStore.d.ts.map +1 -1
  33. package/dist/cjs/persistence/OpenSearchStore.js +2 -2
  34. package/dist/cjs/persistence/OpenSearchStore.js.map +1 -1
  35. package/dist/cjs/persistence/RedisStore.d.ts +1 -1
  36. package/dist/cjs/persistence/RedisStore.d.ts.map +1 -1
  37. package/dist/cjs/providers/AnthropicProvider.d.ts +2 -5
  38. package/dist/cjs/providers/AnthropicProvider.d.ts.map +1 -1
  39. package/dist/cjs/providers/AnthropicProvider.js +3 -4
  40. package/dist/cjs/providers/AnthropicProvider.js.map +1 -1
  41. package/dist/cjs/providers/DeepSeekProvider.d.ts.map +1 -1
  42. package/dist/cjs/providers/DeepSeekProvider.js +3 -4
  43. package/dist/cjs/providers/DeepSeekProvider.js.map +1 -1
  44. package/dist/cjs/providers/FallbackAiProvider.js +1 -1
  45. package/dist/cjs/providers/FallbackAiProvider.js.map +1 -1
  46. package/dist/cjs/providers/GeminiProvider.d.ts +1 -2
  47. package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
  48. package/dist/cjs/providers/GeminiProvider.js +3 -4
  49. package/dist/cjs/providers/GeminiProvider.js.map +1 -1
  50. package/dist/cjs/providers/GenericOpenAICompatibleProvider.js +4 -4
  51. package/dist/cjs/providers/GenericOpenAICompatibleProvider.js.map +1 -1
  52. package/dist/cjs/providers/OpenAIProvider.d.ts.map +1 -1
  53. package/dist/cjs/providers/OpenAIProvider.js +3 -4
  54. package/dist/cjs/providers/OpenAIProvider.js.map +1 -1
  55. package/dist/cjs/providers/OpenRouterProvider.d.ts.map +1 -1
  56. package/dist/cjs/providers/OpenRouterProvider.js +4 -3
  57. package/dist/cjs/providers/OpenRouterProvider.js.map +1 -1
  58. package/dist/cjs/providers/ProviderAdapter.d.ts +5 -9
  59. package/dist/cjs/providers/ProviderAdapter.d.ts.map +1 -1
  60. package/dist/cjs/providers/ProviderAdapter.js +1 -2
  61. package/dist/cjs/providers/ProviderAdapter.js.map +1 -1
  62. package/dist/cjs/providers/ZaiProvider.js +1 -1
  63. package/dist/cjs/providers/ZaiProvider.js.map +1 -1
  64. package/dist/cjs/types/agent.d.ts +1 -1
  65. package/dist/cjs/types/agent.d.ts.map +1 -1
  66. package/dist/cjs/types/ai.d.ts +4 -6
  67. package/dist/cjs/types/ai.d.ts.map +1 -1
  68. package/dist/cjs/types/compaction.d.ts +1 -0
  69. package/dist/cjs/types/compaction.d.ts.map +1 -1
  70. package/dist/cjs/types/errors.d.ts +4 -9
  71. package/dist/cjs/types/errors.d.ts.map +1 -1
  72. package/dist/cjs/types/errors.js +12 -12
  73. package/dist/cjs/types/errors.js.map +1 -1
  74. package/dist/cjs/types/history.d.ts +0 -7
  75. package/dist/cjs/types/history.d.ts.map +1 -1
  76. package/dist/cjs/types/index.d.ts +1 -1
  77. package/dist/cjs/types/index.d.ts.map +1 -1
  78. package/dist/cjs/types/index.js.map +1 -1
  79. package/dist/cjs/types/session.d.ts +1 -1
  80. package/dist/cjs/types/session.d.ts.map +1 -1
  81. package/dist/cjs/utils/clock.js +1 -1
  82. package/dist/cjs/utils/clock.js.map +1 -1
  83. package/dist/cjs/utils/schema.d.ts +2 -11
  84. package/dist/cjs/utils/schema.d.ts.map +1 -1
  85. package/dist/cjs/utils/schema.js +2 -43
  86. package/dist/cjs/utils/schema.js.map +1 -1
  87. package/dist/core/Agent.js +10 -1
  88. package/dist/core/Agent.js.map +1 -1
  89. package/dist/core/CompactionEngine.d.ts.map +1 -1
  90. package/dist/core/CompactionEngine.js +8 -3
  91. package/dist/core/CompactionEngine.js.map +1 -1
  92. package/dist/core/FlowSpec.d.ts +19 -2
  93. package/dist/core/FlowSpec.d.ts.map +1 -1
  94. package/dist/core/FlowSpec.js +254 -57
  95. package/dist/core/FlowSpec.js.map +1 -1
  96. package/dist/core/Prompt.d.ts +16 -0
  97. package/dist/core/Prompt.d.ts.map +1 -1
  98. package/dist/core/Prompt.js +37 -0
  99. package/dist/core/Prompt.js.map +1 -1
  100. package/dist/core/Runner.d.ts +2 -0
  101. package/dist/core/Runner.d.ts.map +1 -1
  102. package/dist/core/Runner.js +27 -16
  103. package/dist/core/Runner.js.map +1 -1
  104. package/dist/core/Speak.d.ts.map +1 -1
  105. package/dist/core/Speak.js +23 -17
  106. package/dist/core/Speak.js.map +1 -1
  107. package/dist/core/Understand.d.ts +4 -3
  108. package/dist/core/Understand.d.ts.map +1 -1
  109. package/dist/core/Understand.js +5 -38
  110. package/dist/core/Understand.js.map +1 -1
  111. package/dist/core/contracts.d.ts +5 -5
  112. package/dist/core/contracts.d.ts.map +1 -1
  113. package/dist/index.d.ts +1 -1
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js.map +1 -1
  116. package/dist/persistence/OpenSearchStore.d.ts +2 -1
  117. package/dist/persistence/OpenSearchStore.d.ts.map +1 -1
  118. package/dist/persistence/OpenSearchStore.js +2 -2
  119. package/dist/persistence/OpenSearchStore.js.map +1 -1
  120. package/dist/persistence/RedisStore.d.ts +1 -1
  121. package/dist/persistence/RedisStore.d.ts.map +1 -1
  122. package/dist/providers/AnthropicProvider.d.ts +2 -5
  123. package/dist/providers/AnthropicProvider.d.ts.map +1 -1
  124. package/dist/providers/AnthropicProvider.js +3 -4
  125. package/dist/providers/AnthropicProvider.js.map +1 -1
  126. package/dist/providers/DeepSeekProvider.d.ts.map +1 -1
  127. package/dist/providers/DeepSeekProvider.js +3 -4
  128. package/dist/providers/DeepSeekProvider.js.map +1 -1
  129. package/dist/providers/FallbackAiProvider.js +1 -1
  130. package/dist/providers/FallbackAiProvider.js.map +1 -1
  131. package/dist/providers/GeminiProvider.d.ts +1 -2
  132. package/dist/providers/GeminiProvider.d.ts.map +1 -1
  133. package/dist/providers/GeminiProvider.js +3 -4
  134. package/dist/providers/GeminiProvider.js.map +1 -1
  135. package/dist/providers/GenericOpenAICompatibleProvider.js +4 -4
  136. package/dist/providers/GenericOpenAICompatibleProvider.js.map +1 -1
  137. package/dist/providers/OpenAIProvider.d.ts.map +1 -1
  138. package/dist/providers/OpenAIProvider.js +3 -4
  139. package/dist/providers/OpenAIProvider.js.map +1 -1
  140. package/dist/providers/OpenRouterProvider.d.ts.map +1 -1
  141. package/dist/providers/OpenRouterProvider.js +4 -3
  142. package/dist/providers/OpenRouterProvider.js.map +1 -1
  143. package/dist/providers/ProviderAdapter.d.ts +5 -9
  144. package/dist/providers/ProviderAdapter.d.ts.map +1 -1
  145. package/dist/providers/ProviderAdapter.js +1 -2
  146. package/dist/providers/ProviderAdapter.js.map +1 -1
  147. package/dist/providers/ZaiProvider.js +1 -1
  148. package/dist/providers/ZaiProvider.js.map +1 -1
  149. package/dist/types/agent.d.ts +1 -1
  150. package/dist/types/agent.d.ts.map +1 -1
  151. package/dist/types/ai.d.ts +4 -6
  152. package/dist/types/ai.d.ts.map +1 -1
  153. package/dist/types/compaction.d.ts +1 -0
  154. package/dist/types/compaction.d.ts.map +1 -1
  155. package/dist/types/errors.d.ts +4 -9
  156. package/dist/types/errors.d.ts.map +1 -1
  157. package/dist/types/errors.js +12 -12
  158. package/dist/types/errors.js.map +1 -1
  159. package/dist/types/history.d.ts +0 -7
  160. package/dist/types/history.d.ts.map +1 -1
  161. package/dist/types/index.d.ts +1 -1
  162. package/dist/types/index.d.ts.map +1 -1
  163. package/dist/types/index.js.map +1 -1
  164. package/dist/types/session.d.ts +1 -1
  165. package/dist/types/session.d.ts.map +1 -1
  166. package/dist/utils/clock.js +1 -1
  167. package/dist/utils/clock.js.map +1 -1
  168. package/dist/utils/schema.d.ts +2 -11
  169. package/dist/utils/schema.d.ts.map +1 -1
  170. package/dist/utils/schema.js +2 -42
  171. package/dist/utils/schema.js.map +1 -1
  172. package/docs/concepts/pipeline.md +2 -2
  173. package/docs/concepts/runs-and-waits.md +1 -1
  174. package/docs/guides/actions-and-events.md +1 -1
  175. package/docs/guides/branching.md +1 -1
  176. package/docs/guides/compaction.md +2 -2
  177. package/docs/guides/error-handling.md +1 -1
  178. package/docs/guides/flow-control.md +1 -1
  179. package/docs/guides/persistence.md +1 -1
  180. package/docs/migration/v3-to-v4.md +2 -2
  181. package/docs/reference/agent.md +1 -1
  182. package/docs/reference/errors.md +10 -7
  183. package/docs/reference/flow-spec.md +29 -5
  184. package/docs/reference/outcomes.md +1 -1
  185. package/docs/reference/providers.md +2 -0
  186. package/docs/reference/step.md +1 -1
  187. package/docs/reference/stores.md +5 -3
  188. package/docs/start/01-install.md +5 -3
  189. package/package.json +1 -1
  190. package/src/core/Agent.ts +12 -1
  191. package/src/core/CompactionEngine.ts +10 -3
  192. package/src/core/FlowSpec.ts +263 -65
  193. package/src/core/Prompt.ts +40 -0
  194. package/src/core/Runner.ts +27 -14
  195. package/src/core/Speak.ts +23 -16
  196. package/src/core/Understand.ts +5 -39
  197. package/src/core/contracts.ts +5 -3
  198. package/src/index.ts +0 -1
  199. package/src/persistence/OpenSearchStore.ts +3 -2
  200. package/src/persistence/RedisStore.ts +1 -1
  201. package/src/providers/AnthropicProvider.ts +4 -9
  202. package/src/providers/DeepSeekProvider.ts +2 -4
  203. package/src/providers/FallbackAiProvider.ts +1 -1
  204. package/src/providers/GeminiProvider.ts +3 -6
  205. package/src/providers/GenericOpenAICompatibleProvider.ts +4 -4
  206. package/src/providers/OpenAIProvider.ts +3 -4
  207. package/src/providers/OpenRouterProvider.ts +4 -2
  208. package/src/providers/ProviderAdapter.ts +6 -11
  209. package/src/providers/ZaiProvider.ts +1 -1
  210. package/src/types/agent.ts +1 -1
  211. package/src/types/ai.ts +4 -6
  212. package/src/types/compaction.ts +1 -0
  213. package/src/types/errors.ts +11 -12
  214. package/src/types/history.ts +0 -10
  215. package/src/types/index.ts +0 -1
  216. package/src/types/session.ts +1 -1
  217. package/src/utils/clock.ts +1 -1
  218. package/src/utils/schema.ts +2 -48
  219. package/dist/cjs/providers/index.d.ts +0 -26
  220. package/dist/cjs/providers/index.d.ts.map +0 -1
  221. package/dist/cjs/providers/index.js +0 -30
  222. package/dist/cjs/providers/index.js.map +0 -1
  223. package/dist/cjs/utils/clone.d.ts +0 -8
  224. package/dist/cjs/utils/clone.d.ts.map +0 -1
  225. package/dist/cjs/utils/clone.js +0 -32
  226. package/dist/cjs/utils/clone.js.map +0 -1
  227. package/dist/cjs/utils/index.d.ts +0 -9
  228. package/dist/cjs/utils/index.d.ts.map +0 -1
  229. package/dist/cjs/utils/index.js +0 -29
  230. package/dist/cjs/utils/index.js.map +0 -1
  231. package/dist/providers/index.d.ts +0 -26
  232. package/dist/providers/index.d.ts.map +0 -1
  233. package/dist/providers/index.js +0 -17
  234. package/dist/providers/index.js.map +0 -1
  235. package/dist/utils/clone.d.ts +0 -8
  236. package/dist/utils/clone.d.ts.map +0 -1
  237. package/dist/utils/clone.js +0 -29
  238. package/dist/utils/clone.js.map +0 -1
  239. package/dist/utils/index.d.ts +0 -9
  240. package/dist/utils/index.d.ts.map +0 -1
  241. package/dist/utils/index.js +0 -9
  242. package/dist/utils/index.js.map +0 -1
  243. package/src/providers/index.ts +0 -38
  244. package/src/utils/clone.ts +0 -34
  245. package/src/utils/index.ts +0 -18
@@ -100,8 +100,14 @@ export interface FlowSpec {
100
100
  tools?: string[];
101
101
  }
102
102
 
103
- /** What a flow's names resolve against: the agent's fields, actions, events, conditions and tools. */
104
- export type Registries = Pick<AgentOptions, "fields" | "actions" | "events" | "conditions" | "tools">;
103
+ /**
104
+ * What a flow's names resolve against: the agent's fields, actions, events,
105
+ * conditions and tools. With `flows`, a literal `{ flow }` target must name
106
+ * one of them; the agent passes its own.
107
+ */
108
+ export type Registries = Pick<AgentOptions, "fields" | "actions" | "events" | "conditions" | "tools"> & {
109
+ flows?: ReadonlyArray<{ id: string }>;
110
+ };
105
111
 
106
112
  // ── fromSpec / toSpec ───────────────────────────────────────────────────
107
113
 
@@ -113,9 +119,7 @@ export type Registries = Pick<AgentOptions, "fields" | "actions" | "events" | "c
113
119
  */
114
120
  export function fromSpec<C = unknown, D = LooseData>(spec: FlowSpec): Flow<C, D> {
115
121
  const clean = stripNulls(spec);
116
- if (!Array.isArray(clean.steps)) {
117
- throw problem(`flow "${clean.id}"`, "has no steps list", "Write steps as a list, even an empty one.");
118
- }
122
+ checkShape(clean);
119
123
  const { steps, ...rest } = clean;
120
124
  const flow: Flow<unknown, LooseData> = { ...rest, steps: steps.map(fromStepSpec) };
121
125
  return flow as Flow<C, D>;
@@ -273,15 +277,16 @@ interface LooseFlow {
273
277
  tools?: string[];
274
278
  }
275
279
 
276
- const BUILT_IN_CONDITIONS = ["equals", "known", "silenced"];
280
+ export const BUILT_IN_CONDITIONS = ["equals", "known", "silenced"];
277
281
 
278
282
  const DURATION_HINT = 'Write a number and a unit: "30s", "5m", "24h" or "3d".';
279
283
 
280
284
  /** The four keys one of which makes an `on[]` entry a trigger. */
281
285
  const TRIGGER_KINDS = ["message", "mention", "silence", "event"] as const;
282
286
 
283
- /** The six keys one of which makes a step do something. */
284
- const STEP_DOES = ["prompt", "collect", "say", "do", "wait", "if"] as const;
287
+ const ON_END = ["end", "stay", "reset"];
288
+
289
+ const INSTRUCTION_KINDS = ["must", "never", "should"];
285
290
 
286
291
  /**
287
292
  * Check a flow, typed or as a spec, against the agent's registries. Throws
@@ -294,7 +299,8 @@ export function validateFlow<C = unknown, D = LooseData>(
294
299
  ): { warnings: string[] } {
295
300
  // Nulls mean "not set" in a spec; a typed flow has none, so one pass serves both forms.
296
301
  const flow: LooseFlow = stripNulls(input);
297
- const { fields, actions = {}, events = {}, conditions = {}, tools = [] } = registries;
302
+ checkShape(flow);
303
+ const { fields, actions = {}, events = {}, tools = [] } = registries;
298
304
  const toolIds = new Set(tools.map((tool) => tool.id));
299
305
  const warnings: string[] = [];
300
306
 
@@ -302,9 +308,6 @@ export function validateFlow<C = unknown, D = LooseData>(
302
308
  throw problem("flow", "has no id", "Give the flow a short unique id.");
303
309
  }
304
310
  const flowAt = `flow "${flow.id}"`;
305
- if (!Array.isArray(flow.steps)) {
306
- throw problem(flowAt, "has no steps list", "Write steps as a list, even an empty one.");
307
- }
308
311
 
309
312
  const index = new Map<string, number>();
310
313
  flow.steps.forEach((step, i) => {
@@ -323,11 +326,7 @@ export function validateFlow<C = unknown, D = LooseData>(
323
326
  throw problem(flowAt, "has triggers but no steps", "Add at least one step or remove `on`.");
324
327
  }
325
328
 
326
- const slug = (name: string, at: string, where: string): void => {
327
- if (!own(fields, name)) {
328
- throw problem(at, `unknown field "${name}" in ${where}`, "Add it to the agent's fields or fix the slug.");
329
- }
330
- };
329
+ const slug = (name: string, at: string, where: string): void => checkSlug(fields, name, at, where);
331
330
 
332
331
  const toolNames = (names: string[] | undefined, at: string): void => {
333
332
  for (const name of names ?? []) {
@@ -335,38 +334,7 @@ export function validateFlow<C = unknown, D = LooseData>(
335
334
  }
336
335
  };
337
336
 
338
- const pred = (value: LoosePred | undefined, at: string, where: string): void => {
339
- if (value === undefined || typeof value === "function") return;
340
- for (const [name, arg] of Object.entries(value)) {
341
- if (name === "equals") {
342
- if (arg === null || typeof arg !== "object" || Array.isArray(arg)) {
343
- throw problem(at, `${where}.equals is not an object`, "Write equals as { field: value }.");
344
- }
345
- for (const [field, given] of Object.entries(arg)) {
346
- slug(field, at, `${where}.equals`);
347
- const def = fields[field];
348
- if (!matches(def, given)) {
349
- throw problem(
350
- at,
351
- `${where}.equals gives "${field}" a ${describe(given)}, but the field is a ${def.type}`,
352
- `Write a ${def.type}; values are not coerced.`,
353
- );
354
- }
355
- }
356
- } else if (name === "known") {
357
- if (!Array.isArray(arg)) throw problem(at, `${where}.known is not a list`, "Write known as [field, ...].");
358
- for (const field of arg) slug(String(field), at, `${where}.known`);
359
- } else if (name === "silenced") {
360
- if (typeof arg !== "boolean") throw problem(at, `${where}.silenced is not a boolean`, "Write true or false.");
361
- } else if (!own(conditions, name)) {
362
- throw problem(
363
- at,
364
- `unknown condition "${name}" in ${where}`,
365
- `Register it in conditions or use ${BUILT_IN_CONDITIONS.join(", ")}.`,
366
- );
367
- }
368
- }
369
- };
337
+ const pred = (value: LoosePred | undefined, at: string, where: string): void => checkPred(value, at, where, registries);
370
338
 
371
339
  const duration = (value: string | undefined, at: string, where: string): void => {
372
340
  if (value !== undefined && !isDuration(value)) {
@@ -384,7 +352,16 @@ export function validateFlow<C = unknown, D = LooseData>(
384
352
  }
385
353
  return to;
386
354
  }
387
- if ("flow" in next) return undefined;
355
+ if ("flow" in next) {
356
+ // A templated id resolves per run; only a literal one can be checked now. A warning, not a throw:
357
+ // a host that drops one bad row keeps the rest of its agent, and the Runner skips this move as flow-gone.
358
+ const known = registries.flows?.map((f) => f.id);
359
+ if (known && !next.flow.includes("{{") && !known.includes(next.flow)) {
360
+ const fix = known.length ? `Use one of ${known.map((id) => `"${id}"`).join(", ")}, or add the flow.` : "Add the flow to the agent.";
361
+ warnings.push(`${at}: ${where} names flow "${next.flow}", which this agent does not have; a run skips this move with flow-gone. ${fix}`);
362
+ }
363
+ return undefined;
364
+ }
388
365
  const to = index.get(next.step);
389
366
  if (to === undefined) {
390
367
  throw problem(at, `${where} points at step "${next.step}", which does not exist`, 'Use an existing step id or "end".');
@@ -482,17 +459,6 @@ export function validateFlow<C = unknown, D = LooseData>(
482
459
 
483
460
  flow.steps.forEach((step, i) => {
484
461
  const at = `${flowAt}, step "${step.id}"`;
485
- // Same reasoning as the trigger above: a step that does none of the five
486
- // things is a step the run walks straight past, silently. `kind` alone is
487
- // not enough — the spec form drops it and keeps the body, so what counts
488
- // is whether the body says what to do.
489
- if (!STEP_DOES.some((key) => step[key] !== undefined)) {
490
- throw problem(
491
- at,
492
- "does nothing",
493
- "A step talks (`prompt` / `collect`), says (`say`), acts (`do`), waits (`wait`) or forks (`if`).",
494
- );
495
- }
496
462
  const thenTo = edge(i, step.then, at, "then");
497
463
  edge(i, step.else, at, "else");
498
464
 
@@ -518,6 +484,14 @@ export function validateFlow<C = unknown, D = LooseData>(
518
484
  if (branch.when === undefined && branch.if === undefined) {
519
485
  throw problem(at, `${where} has neither when nor if`, "Give the branch an AI condition (when) or a code one (if).");
520
486
  }
487
+ // No understand call judges a wait, so an AI condition there never fires. A warning, not a throw: the
488
+ // flow still runs as it did, a reply goes to `else`, and editors that offered the branch keep their rows.
489
+ if (branch.when !== undefined && step.wait !== undefined) {
490
+ warnings.push(
491
+ `${at}: ${where} is a "when" branch on a wait step, which no call judges, so a reply goes to else. ` +
492
+ 'Use "if", or move the branch to a talk step.',
493
+ );
494
+ }
521
495
  pred(branch.if, at, `${where}.if`);
522
496
  edge(i, branch.then, at, `${where}.then`);
523
497
  });
@@ -554,6 +528,228 @@ export function validateFlow<C = unknown, D = LooseData>(
554
528
  return { warnings };
555
529
  }
556
530
 
531
+ function checkSlug(fields: FieldDefs, name: string, at: string, where: string): void {
532
+ if (!own(fields, name)) {
533
+ throw problem(at, `unknown field "${name}" in ${where}`, "Add it to the agent's fields or fix the slug.");
534
+ }
535
+ }
536
+
537
+ /**
538
+ * A predicate's names against the registries: the fields `equals` and `known`
539
+ * read, and every other key as one of the agent's conditions. A function is
540
+ * code and passes as is. The agent runs this on its own instructions too.
541
+ */
542
+ export function checkPred(value: LoosePred | undefined, at: string, where: string, registries: Registries): void {
543
+ if (value === undefined || typeof value === "function") return;
544
+ const { fields, conditions = {} } = registries;
545
+ for (const [name, arg] of Object.entries(value)) {
546
+ if (name === "equals") {
547
+ if (!isObject(arg)) {
548
+ throw problem(at, `${where}.equals is not an object`, "Write equals as { field: value }.");
549
+ }
550
+ for (const [field, given] of Object.entries(arg)) {
551
+ checkSlug(fields, field, at, `${where}.equals`);
552
+ const def = fields[field];
553
+ if (!matches(def, given)) {
554
+ const { listed, expected, got, fix } = mismatch(def, given);
555
+ throw listed
556
+ ? problem(at, `${where}.equals gives "${field}" ${got}, which is not ${expected}`, fix)
557
+ : problem(at, `${where}.equals gives "${field}" ${article(got)} ${got}, but the field is ${expected}`, `Write ${expected}; values are not coerced.`);
558
+ }
559
+ }
560
+ } else if (name === "known") {
561
+ if (!Array.isArray(arg)) throw problem(at, `${where}.known is not a list`, "Write known as [field, ...].");
562
+ for (const field of arg) checkSlug(fields, String(field), at, `${where}.known`);
563
+ } else if (name === "silenced") {
564
+ if (typeof arg !== "boolean") throw problem(at, `${where}.silenced is not a boolean`, "Write true or false.");
565
+ } else if (!own(conditions, name)) {
566
+ throw problem(
567
+ at,
568
+ `unknown condition "${name}" in ${where}`,
569
+ `Register it in conditions or use ${BUILT_IN_CONDITIONS.join(", ")}.`,
570
+ );
571
+ }
572
+ }
573
+ }
574
+
575
+ // ── Shape ───────────────────────────────────────────────────────────────
576
+
577
+ /**
578
+ * The JSON shape, checked before any name is. Stored rows and generated specs
579
+ * are untrusted: a string where a list belongs used to crash with a raw
580
+ * TypeError naming no flow, or pass and misbehave at run time (`collect:
581
+ * "nome"` read as the fields "n", "o", "m", "e").
582
+ */
583
+ function checkShape(value: unknown): void {
584
+ if (!isObject(value)) throw problem("flow", `is ${show(value)}, not an object`, "Pass the flow itself: { id, name, steps }.");
585
+ const flowAt = typeof value.id === "string" ? `flow "${value.id}"` : "flow";
586
+ if (!Array.isArray(value.steps)) {
587
+ throw problem(flowAt, "has no steps list", "Write steps as a list, even an empty one.");
588
+ }
589
+ text(value, ["description", "anchor"], flowAt);
590
+ for (const key of ["collect", "clearOnStart", "tools"]) listOf(value[key], key, flowAt, "string");
591
+ oneOf(value.onEnd, "onEnd", ON_END, flowAt);
592
+ predShape(value.while, "while", flowAt);
593
+ instructionsShape(value.instructions, flowAt);
594
+
595
+ listOf(value.on, "on", flowAt, "object").forEach((trigger, i) => {
596
+ const at = `${flowAt}, trigger #${i + 1}`;
597
+ if (!isObject(trigger)) return;
598
+ listOf(trigger.message, "message", at, "string");
599
+ listOf(trigger.mention, "mention", at, "string");
600
+ predShape(trigger.if, "if", at);
601
+ const { repeat } = trigger;
602
+ if (repeat !== undefined && repeat !== "once" && repeat !== "always" && !(isObject(repeat) && typeof repeat.cooldown === "string")) {
603
+ throw problem(at, `repeat is ${show(repeat)}`, 'Use "once", "always" or { cooldown: "24h" }.');
604
+ }
605
+ });
606
+
607
+ listOf(value.steps, "steps", flowAt, "object").forEach((step, i) => {
608
+ if (!isObject(step)) return;
609
+ const at = typeof step.id === "string" ? `${flowAt}, step "${step.id}"` : `${flowAt}, step #${i + 1}`;
610
+ stepShape(step, at);
611
+ });
612
+ }
613
+
614
+ /** One step: what it does, and every value the right kind of thing. */
615
+ function stepShape(step: Record<string, unknown>, at: string): void {
616
+ // A step that does none of the five things is one the run walks straight
617
+ // past; one that does two runs only the first, and the other never happens.
618
+ const kinds = bodyKinds(step);
619
+ if (kinds.length === 0) {
620
+ throw problem(
621
+ at,
622
+ "does nothing",
623
+ "A step talks (`prompt` / `collect`), says (`say`), acts (`do`), waits (`wait`) or forks (`if`).",
624
+ );
625
+ }
626
+ if (kinds.length > 1) {
627
+ throw problem(at, `mixes ${and(kinds.map((k) => `"${k}"`))}`, "A step does one thing. Split it into one step per kind.");
628
+ }
629
+ // The spec's `kind` must say what the body does; the Runner reads the body.
630
+ if (step.kind !== undefined && step.kind !== kinds[0]) {
631
+ throw problem(at, `has kind ${show(step.kind)}, but its body is a "${kinds[0]}" step`, `Set kind to "${kinds[0]}", or change the body to match.`);
632
+ }
633
+ text(step, ["say", "prompt", "question"], at);
634
+ listOf(step.collect, "collect", at, "string");
635
+ listOf(step.tools, "tools", at, "string");
636
+ if (step.ask !== undefined && !isObject(step.ask)) {
637
+ throw problem(at, `ask is ${show(step.ask)}, not an object`, 'Write ask as { field: "how to ask" }.');
638
+ }
639
+ if (step.with !== undefined && !isObject(step.with)) {
640
+ throw problem(at, `with is ${show(step.with)}, not an object`, "Write with as { parameter: value }.");
641
+ }
642
+ const { maxAsks } = step;
643
+ if (maxAsks !== undefined && !(typeof maxAsks === "number" && Number.isInteger(maxAsks) && maxAsks >= 1)) {
644
+ throw problem(at, `maxAsks is ${show(maxAsks)}, not a whole number of 1 or more`, "Write a number like 3.");
645
+ }
646
+ if (isObject(step.wait) && typeof step.wait.event !== "string") {
647
+ throw problem(at, "wait has no event", 'Write wait: { event: "name" } to wait for an event, or a duration like "1h".');
648
+ }
649
+ predShape(step.if, "if", at);
650
+ for (const key of ["then", "else", "onFail"]) nextShape(step[key], key, at);
651
+ instructionsShape(step.instructions, at);
652
+ listOf(step.branches, "branches", at, "object").forEach((branch, j) => {
653
+ if (!isObject(branch)) return;
654
+ const where = `branches[${j}]`;
655
+ if (branch.when !== undefined && typeof branch.when !== "string") {
656
+ throw problem(at, `${where}.when is ${show(branch.when)}, not text`, "Write the condition as one sentence.");
657
+ }
658
+ predShape(branch.if, `${where}.if`, at);
659
+ nextShape(branch.then, `${where}.then`, at);
660
+ });
661
+ }
662
+
663
+ /** The kinds a step's body carries, by its keys. A talk step is `collect` with a list, `prompt` with a guideline alone. */
664
+ function bodyKinds(step: Record<string, unknown>): StepKind[] {
665
+ const kinds: StepKind[] = [];
666
+ if (step.collect !== undefined) kinds.push("collect");
667
+ else if (step.prompt !== undefined) kinds.push("prompt");
668
+ if (step.say !== undefined) kinds.push("say");
669
+ if (step.do !== undefined) kinds.push("do");
670
+ if (step.wait !== undefined) kinds.push(typeof step.wait === "string" ? "wait" : "waitEvent");
671
+ if (step.if !== undefined) kinds.push("if");
672
+ return kinds;
673
+ }
674
+
675
+ function instructionsShape(value: unknown, at: string): void {
676
+ listOf(value, "instructions", at, "object").forEach((ins, i) => {
677
+ if (!isObject(ins)) return;
678
+ const where = `instructions[${i}]`;
679
+ oneOf(ins.kind, `${where}.kind`, INSTRUCTION_KINDS, at);
680
+ if (typeof ins.prompt !== "string") {
681
+ throw problem(at, `${where}.prompt is ${show(ins.prompt)}, not text`, "Write the rule as a sentence.");
682
+ }
683
+ if (ins.when !== undefined && typeof ins.when !== "string") listOf(ins.when, `${where}.when`, at, "string");
684
+ predShape(ins.if, `${where}.if`, at);
685
+ });
686
+ }
687
+
688
+ /** A step id, `"end"`, `{ step, clear? }` or `{ flow, input? }`. */
689
+ function nextShape(value: unknown, where: string, at: string): void {
690
+ if (value === undefined || typeof value === "string") return;
691
+ if (isObject(value) && (typeof value.step === "string" || typeof value.flow === "string")) {
692
+ listOf(value.clear, `${where}.clear`, at, "string");
693
+ return;
694
+ }
695
+ throw problem(at, `${where} is ${show(value)}, not a step id or a target`, 'Write a step id, "end", { step: "id" } or { flow: "id" }.');
696
+ }
697
+
698
+ /** A condition object, or code on a typed flow. */
699
+ function predShape(value: unknown, where: string, at: string): void {
700
+ if (value === undefined || typeof value === "function" || isObject(value)) return;
701
+ throw problem(at, `${where} is ${show(value)}, not a condition`, 'Write it as an object, e.g. { known: ["nome"] }.');
702
+ }
703
+
704
+ /** Each named key, when set, must be text. */
705
+ function text(owner: Record<string, unknown>, keys: string[], at: string): void {
706
+ for (const key of keys) {
707
+ const value = owner[key];
708
+ if (value !== undefined && typeof value !== "string") {
709
+ throw problem(at, `${key} is ${show(value)}, not text`, `Write ${key} as a string.`);
710
+ }
711
+ }
712
+ }
713
+
714
+ function oneOf(value: unknown, where: string, allowed: string[], at: string): void {
715
+ if (value === undefined || (typeof value === "string" && allowed.includes(value))) return;
716
+ throw problem(at, `${where} is ${show(value)}, which is not one of ${allowed.map((v) => `"${v}"`).join(", ")}`, "Use one of them.");
717
+ }
718
+
719
+ /** A list whose items are all strings or all objects; absent reads as empty. */
720
+ function listOf(value: unknown, where: string, at: string, item: "string" | "object"): unknown[] {
721
+ if (value === undefined) return [];
722
+ if (!Array.isArray(value)) {
723
+ const example = item === "string" && typeof value === "string" ? `${where}: ${JSON.stringify([value])}` : `${where} as a list`;
724
+ throw problem(at, `${where} is ${show(value)}, not a list`, `Write ${example}.`);
725
+ }
726
+ value.forEach((entry, i) => {
727
+ if (item === "string" ? typeof entry !== "string" : !isObject(entry)) {
728
+ throw problem(at, `${where}[${i}] is ${show(entry)}, not ${item === "string" ? "text" : "an object"}`, `Write each entry of ${where} as ${item === "string" ? "a string" : "an object"}.`);
729
+ }
730
+ });
731
+ return value;
732
+ }
733
+
734
+ /** A plain object: not null, not a list, not a function. */
735
+ function isObject(value: unknown): value is Record<string, unknown> {
736
+ return typeof value === "object" && value !== null && !Array.isArray(value);
737
+ }
738
+
739
+ /** A wrong value as an error names it: short scalars verbatim, anything else by kind. */
740
+ function show(value: unknown): string {
741
+ if (typeof value === "string") return JSON.stringify(value.length > 40 ? `${value.slice(0, 37)}...` : value);
742
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
743
+ if (value === undefined) return "missing";
744
+ if (value === null) return "null";
745
+ return Array.isArray(value) ? "a list" : typeof value === "function" ? "a function" : "an object";
746
+ }
747
+
748
+ /** `"a"`, `"a" and "b"`, `"a", "b" and "c"`. */
749
+ function and(items: string[]): string {
750
+ return items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
751
+ }
752
+
557
753
  function matchesParam(def: ParamDef, value: unknown): boolean {
558
754
  if (def.type === "array") return Array.isArray(value) && value.every((item) => matches(def.items, item));
559
755
  return matches(def, value);
@@ -577,16 +773,16 @@ function matches(def: ScalarDef, value: unknown): boolean {
577
773
  * What a rejected value should have been and what it was. A value of the right type that is
578
774
  * not a listed one names the listed values: "must be a string, got string" would say nothing.
579
775
  */
580
- function mismatch(def: ParamDef, value: unknown): { expected: string; got: string; fix: string } {
776
+ function mismatch(def: ParamDef, value: unknown): { listed: boolean; expected: string; got: string; fix: string } {
581
777
  const scalar = def.type === "array" ? def.items : def;
582
778
  const { enum: allowed, ...typeOnly } = scalar;
583
779
  const items = def.type === "array" && Array.isArray(value) ? value : [value];
584
780
  const off = allowed ? items.findIndex((item) => matches(typeOnly, item) && !matches(scalar, item)) : -1;
585
781
  if (!allowed || off === -1) {
586
- return { expected: describeDef(def), got: describe(value), fix: "Values are not coerced; write the right type." };
782
+ return { listed: false, expected: describeDef(def), got: describe(value), fix: "Values are not coerced; write the right type." };
587
783
  }
588
784
  const list = (v: unknown) => JSON.stringify(v);
589
- return { expected: `one of ${allowed.map(list).join(", ")}`, got: list(items[off]), fix: "Use one of the listed values." };
785
+ return { listed: true, expected: `one of ${allowed.map(list).join(", ")}`, got: list(items[off]), fix: "Use one of the listed values." };
590
786
  }
591
787
 
592
788
  function describe(value: unknown): string {
@@ -668,6 +864,8 @@ export function flowSpecSchema(registries: Registries): StructuredSchema {
668
864
  const branches = orNull(
669
865
  list(union([closed({ when: STRING, then: next }), closed({ if: condition, then: next })]), "Exits judged while the step asks"),
670
866
  );
867
+ // No understand call judges a wait, so its branches are code only.
868
+ const waitBranches = orNull(list(closed({ if: condition, then: next }), "Exits checked by code when the lead replies before the wait ends"));
671
869
  const stepBase = { id: STRING, label: orNull(STRING), then: nextOrNull };
672
870
  const step = union([
673
871
  closed({ ...stepBase, kind: enumOf(["prompt"]), prompt: { type: "string", description: "Guideline for the AI's next message" }, branches }),
@@ -697,7 +895,7 @@ export function flowSpecSchema(registries: Registries): StructuredSchema {
697
895
  wait: { ...duration, description: "Park this long; then = time passed, else = the lead replied" },
698
896
  businessHours: orNull(BOOLEAN),
699
897
  else: nextOrNull,
700
- branches,
898
+ branches: waitBranches,
701
899
  }),
702
900
  eventNames.length
703
901
  ? closed({
@@ -160,3 +160,43 @@ export function pendingSection(
160
160
  ...lines,
161
161
  ].join("\n");
162
162
  }
163
+
164
+ /** Gemini rejects any other character in a property name. */
165
+ const SAFE_KEY = /^[a-zA-Z0-9_-]+$/;
166
+
167
+ /**
168
+ * Envelope property names for ids the schema cannot carry. A safe id keeps
169
+ * its own name; anything else (run ids carry `#`, `:` and `/`) or a name the
170
+ * envelope already uses (`reserved`) gets a short alias, mapped back when the
171
+ * reply is parsed.
172
+ */
173
+ export class Aliases {
174
+ private readonly byReal = new Map<string, string>();
175
+ private readonly byAlias = new Map<string, string>();
176
+ private n = 0;
177
+
178
+ constructor(reserved: readonly string[] = []) {
179
+ for (const name of reserved) this.byAlias.set(name, name);
180
+ }
181
+
182
+ of(real: string, prefix = "k"): string {
183
+ const seen = this.byReal.get(real);
184
+ if (seen) return seen;
185
+ const alias = SAFE_KEY.test(real) && !this.byAlias.has(real) ? real : this.fresh(prefix);
186
+ this.byAlias.set(alias, real);
187
+ this.byReal.set(real, alias);
188
+ return alias;
189
+ }
190
+
191
+ /** Unknown aliases come back as they are; Runner drops keys it does not know. */
192
+ real(alias: string): string {
193
+ return this.byAlias.get(alias) ?? alias;
194
+ }
195
+
196
+ private fresh(prefix: string): string {
197
+ let alias: string;
198
+ do alias = `${prefix}${++this.n}`;
199
+ while (this.byAlias.has(alias));
200
+ return alias;
201
+ }
202
+ }
@@ -13,8 +13,7 @@ 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";
15
15
  import type { Run, Session, StepOutcome, StepOutcomeCode, StepOutcomeKind, TriggerKind } from "../types/session.js";
16
- import { cloneDeep } from "../utils/clone.js";
17
- import { parseDuration } from "../utils/duration.js";
16
+ import { isDuration, parseDuration } from "../utils/duration.js";
18
17
  import { OUTCOME_MESSAGES } from "../utils/outcomes.js";
19
18
  import { coerceField, DEFAULT_MAX_ASKS, extractMode, isKnown, pendingFields } from "../utils/schema.js";
20
19
  import { render, renderDeep } from "../utils/template.js";
@@ -202,7 +201,7 @@ export class Runner<C = unknown, D = unknown> {
202
201
  what,
203
202
  kind: what.kind,
204
203
  triggerKey: what.kind === "message" ? (what.id ?? what.at) : what.key,
205
- session: input.session ? cloneDeep(input.session) : freshSession<D>(input.sessionId),
204
+ session: input.session ? structuredClone(input.session) : freshSession<D>(input.sessionId),
206
205
  original: input.session,
207
206
  now,
208
207
  nowIso,
@@ -350,8 +349,10 @@ export class Runner<C = unknown, D = unknown> {
350
349
  /** `silence:<flowId>:<sessionId>:<lastAssistantAtMs>`, honoured only while the blob still shows that silence. */
351
350
  private silenceWake(turn: Turn<C, D>, key: string): void {
352
351
  const rest = key.slice("silence:".length);
353
- const flowId = rest.slice(0, rest.indexOf(":"));
354
352
  const ms = Number(rest.slice(rest.lastIndexOf(":") + 1));
353
+ // A flow id may hold a ":" itself, so cut at the session id rather than at the first ":".
354
+ const cut = rest.lastIndexOf(`:${turn.session.id}:`);
355
+ const flowId = rest.slice(0, cut === -1 ? rest.indexOf(":") : cut);
355
356
  const { lastAssistantAt } = turn.session;
356
357
  const flow = this.flows.get(flowId);
357
358
  if (!flow) {
@@ -413,11 +414,8 @@ export class Runner<C = unknown, D = unknown> {
413
414
  return null;
414
415
  };
415
416
  if (opts.trigger?.if && !this.holds(opts.trigger.if, turn, run)) return null;
416
- const heldAt = this.claimAt(turn, dedupeKey);
417
- if (heldAt !== undefined) {
418
- if (typeof repeat === "string") return skip("already-claimed");
419
- if (turn.now.getTime() - Date.parse(heldAt) < parseDuration(repeat.cooldown)) return skip("cooldown");
420
- }
417
+ const blocked = this.repeatBlocks(turn, dedupeKey, repeat);
418
+ if (blocked) return skip(blocked);
421
419
  if (hop >= MAX_HOP) return skip("hop-limit");
422
420
  const live = session.runs.find((r) => r.flowId === flow.id && r.anchor === anchor);
423
421
  if (live) {
@@ -462,10 +460,15 @@ export class Runner<C = unknown, D = unknown> {
462
460
 
463
461
  private repeatAllows(turn: Turn<C, D>, flow: Flow<C, D>, trigger: Trigger<C, D>, key: string): boolean {
464
462
  const repeat = trigger.repeat ?? defaultRepeat(triggerKind(trigger));
465
- const heldAt = this.claimAt(turn, `${flow.id}:${this.anchorOf(turn, flow)}:${repeat === "always" ? key : ""}`);
466
- if (heldAt === undefined) return true;
467
- if (typeof repeat === "string") return false;
468
- return turn.now.getTime() - Date.parse(heldAt) >= parseDuration(repeat.cooldown);
463
+ return !this.repeatBlocks(turn, `${flow.id}:${this.anchorOf(turn, flow)}:${repeat === "always" ? key : ""}`, repeat);
464
+ }
465
+
466
+ /** Why the claim already on `dedupeKey` stops another start, or nothing when there is none or its cooldown has run out. */
467
+ private repeatBlocks(turn: Turn<C, D>, dedupeKey: string, repeat: Repeat): "already-claimed" | "cooldown" | undefined {
468
+ const heldAt = this.claimAt(turn, dedupeKey);
469
+ if (heldAt === undefined) return undefined;
470
+ if (typeof repeat === "string") return "already-claimed";
471
+ return turn.now.getTime() - Date.parse(heldAt) < parseDuration(repeat.cooldown) ? "cooldown" : undefined;
469
472
  }
470
473
 
471
474
  /** A run as it would be if started now, for trigger-level predicates. */
@@ -788,9 +791,12 @@ export class Runner<C = unknown, D = unknown> {
788
791
  const instructions = [...(this.options.instructions ?? []), ...own.instructions].filter((ins) => !ins.if || evaluate(ins.if, ctx, conditions));
789
792
  const all = this.options.tools ?? [];
790
793
  const allowed = own.tools;
794
+ // deferTalk leaves a `deferred` outcome as the step's last line; the retry wake re-runs the step without adding one.
795
+ const last = "idle" in talk ? undefined : talk.run.outcomes.at(-1);
796
+ const retry = !("idle" in talk) && last?.status === "deferred" && last.stepId === talk.step.id;
791
797
  return {
792
798
  talk,
793
- input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind },
799
+ input: turn.what.kind === "message" ? { kind: turn.kind, text: turn.what.text } : { kind: turn.kind, ...(retry ? { retry } : {}) },
794
800
  context: turn.context,
795
801
  data: turn.session.data,
796
802
  history: this.historyOf(turn),
@@ -967,6 +973,13 @@ export class Runner<C = unknown, D = unknown> {
967
973
  result = { failed: error instanceof Error ? error.message : String(error) };
968
974
  }
969
975
  }
976
+ // The handler already ran, so a defer that cannot be parsed must not throw
977
+ // the turn: every replay would repeat the side effect and fail again.
978
+ if ("defer" in result && !isDuration(String(result.defer))) {
979
+ result = {
980
+ failed: `action "${step.do}" asked to defer by "${String(result.defer)}", which is not a duration. Use a value like "2m" or "1h".`,
981
+ };
982
+ }
970
983
  if ("ok" in result) {
971
984
  if (result.spoke) {
972
985
  turn.spokeBy.add(run.id);