@nexalab/agent-sdk 0.1.7 → 0.1.9

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.
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { z } from "zod";
2
3
  import { runNativeToolLoop } from "../native-tool-loop.js";
3
4
  import { namespaceKey } from "../tenancy.js";
4
5
  import { parseStructuredOutput, structuredOutputInstruction } from "../structured-output.js";
@@ -8,6 +9,9 @@ import { diffRequirements, evaluateRequirements } from "./requirements.js";
8
9
  import { missingCollectFields, resolveActiveStep } from "./steps.js";
9
10
  import { missingRequiredBeforeExitTools, missingRequiredTools, resolveStepTools } from "./tool-policy.js";
10
11
  import { publishFlowEvent } from "./events.js";
12
+ const MAX_REPLY_ROUNDS = 6;
13
+ /** Safety cap on how many consecutive silent tool-only steps a single turn can auto-chain through (see the loop in `driveFlow`). Prevents a misconfigured flow (e.g. two tool-only steps whose `when`/`exit` bounce between each other) from looping forever within one turn. */
14
+ const MAX_CHAIN_STEPS = 8;
11
15
  const ONGOING_STATUSES = ["active", "waiting_for_user", "waiting_for_tool", "waiting_for_approval"];
12
16
  function isOngoing(status) {
13
17
  return ONGOING_STATUSES.includes(status);
@@ -88,6 +92,15 @@ export class GuidedFlowRuntime {
88
92
  const runId = randomUUID();
89
93
  const now = () => new Date().toISOString();
90
94
  const existing = await this.options.store.getFlowState(key, flow.id);
95
+ // In a multi-flow agent, a flow with no prior state was necessarily just picked THIS turn by
96
+ // `selectFlow`'s trigger/flowSelector match (an already-ongoing flow always has `existing`
97
+ // state — see `selectFlow`, which returns any ongoing flow before ever consulting triggers).
98
+ // That means `input.text` is the phrase that CAUSED the routing, not an answer to whatever
99
+ // the newly-entered flow's first step asks. Reproduced live: a billing flow whose first step
100
+ // is a bare single-field `collect: ["idTicket"]` swallowed "quiero hacer una factura" itself
101
+ // as the ticket id. Single-flow agents are exempt: there's nothing else the message could be
102
+ // routing to, so treating turn one as a direct answer (no preceding question) is intentional.
103
+ const flowJustSelectedThisTurn = !existing && this.options.flows.length > 1;
91
104
  let record = existing ?? {
92
105
  flowId: flow.id,
93
106
  flowVersion: flow.version,
@@ -121,93 +134,206 @@ export class GuidedFlowRuntime {
121
134
  // --- Extraction sub-call: pull a Zod-validated partial state patch out of free text ---
122
135
  const modelPatchSchema = buildModelPatchSchema(config.stateSchema, config.protectedFields);
123
136
  let modelPatch = {};
124
- try {
125
- const extraction = await this.options.client.complete({
126
- providerId: this.options.providerId,
127
- model: this.options.model,
128
- messages: [
129
- {
130
- role: "system",
131
- content: `${buildGuidedInstructions(config, step, state, this.options.skills)}\n\n${structuredOutputInstruction(modelPatchSchema, "StatePatch")}\nOnly include fields the user explicitly provided in their latest message. Omit fields that are unknown, already known, or not mentioned.`
132
- },
133
- ...input.messages,
134
- { role: "user", content: input.text }
135
- ],
136
- ...(input.signal ? { signal: input.signal } : {})
137
- });
138
- const parsed = parseStructuredOutput(modelPatchSchema, extraction.text);
139
- if (parsed.ok)
140
- modelPatch = parsed.data;
141
- else {
137
+ // A step collecting a single enum field (a numbered choice, per `step.prompt`) has an
138
+ // unambiguous, mechanically checkable answer space — try matching the user's raw text
139
+ // against it (by list index, exact label, or an unambiguous partial match) before ever
140
+ // asking a model to "extract" it. Short colloquial answers ("si", "sip", a bare "1") are
141
+ // exactly where free-text extraction is least reliable — a model call can still say yes
142
+ // but then fail to produce the exact enum literal, or need several attempts. This can't
143
+ // drift that way because there's no free-text interpretation step to drift in; it either
144
+ // finds an unambiguous match or leaves `modelPatch` empty and falls through to the
145
+ // existing extraction call, unchanged from before.
146
+ // A step collecting a single PLAIN free-text field (a name, a comment — no enum, no tool
147
+ // that would need to compose or validate it) has nothing for a model to decide either: the
148
+ // only thing that could ever go there is exactly what the user just typed. Caught live: a
149
+ // model asked to extract a bare "Juan" into a single obvious `firstname` field sometimes
150
+ // just didn't — silently leaving it unset and re-showing the same prompt, for a case with no
151
+ // real ambiguity to weigh. See `singlePlainTextCollectField`'s doc comment for the (narrow)
152
+ // conditions this only ever applies under.
153
+ const deterministicChoice = flowJustSelectedThisTurn
154
+ ? undefined
155
+ : (matchSingleEnumChoice(config.stateSchema, step, input.text) ?? matchSinglePlainTextCollect(config.stateSchema, step, input.text));
156
+ if (deterministicChoice) {
157
+ modelPatch = deterministicChoice;
158
+ }
159
+ else {
160
+ // The current step's single enum `collect` field already went through every
161
+ // deterministic matching strategy above (index, exact label, unambiguous partial) and
162
+ // still found nothing — that's a bounded, mechanically-checkable answer space, so a
163
+ // model guessing at it afterwards can only do worse: it has no new information the
164
+ // deterministic pass didn't already have, just more room to hallucinate a plausible-
165
+ // sounding option the user never actually picked. Caught live: an unrelated word ("pedido")
166
+ // got the extraction call to silently invent a menu choice, skipping straight past the
167
+ // step that was supposed to show the menu at all. Excluding the field from the schema —
168
+ // rather than trusting "Only include fields explicitly provided" to hold — makes it
169
+ // structurally impossible for this one call to set it.
170
+ const enumCollectField = singleEnumCollectField(config.stateSchema, step);
171
+ const extractionSchema = enumCollectField ? modelPatchSchema.omit({ [enumCollectField]: true }) : modelPatchSchema;
172
+ try {
173
+ const extraction = await this.options.client.complete({
174
+ providerId: this.options.providerId,
175
+ model: this.options.model,
176
+ messages: [
177
+ {
178
+ role: "system",
179
+ content: `${buildGuidedInstructions(config, step, state, this.options.skills)}\n\n${structuredOutputInstruction(extractionSchema, "StatePatch")}\nOnly include fields the user explicitly provided in their latest message. Omit fields that are unknown, already known, or not mentioned.`
180
+ },
181
+ ...input.messages,
182
+ { role: "user", content: input.text }
183
+ ],
184
+ // This call must return ONLY a JSON object, never prose — plain instruction text alone
185
+ // measurably failed a non-trivial fraction of the time (real conversations, gpt-4o-mini:
186
+ // the model answering with a sentence like "Perfecto, ..." instead of JSON). `responseFormat`
187
+ // is additive/opt-in on ChatCompletionInput (openai-compatible.ts) and only threaded through
188
+ // the "openai"-format branch, so providers/models that don't support it are unaffected unless
189
+ // they're used here. `temperature: 0` because this is a deterministic extraction task, not a
190
+ // creative one — the previous call left temperature unset (provider/API default).
191
+ responseFormat: { type: "json_object" },
192
+ temperature: 0,
193
+ ...(input.signal ? { signal: input.signal } : {})
194
+ });
195
+ const parsed = parseStructuredOutput(extractionSchema, extraction.text, "StatePatch", {
196
+ normalize: (value) => stripEmptyStringsOnNonStringFields(extractionSchema, value)
197
+ });
198
+ if (parsed.ok)
199
+ modelPatch = parsed.data;
200
+ else {
201
+ await this.announce(input.onEvent, {
202
+ type: "flow.validation.failed",
203
+ flowId: flow.id,
204
+ sessionId: key,
205
+ taskId,
206
+ runId,
207
+ payload: { stage: "extraction", reason: parsed.error }
208
+ });
209
+ }
210
+ }
211
+ catch (error) {
142
212
  await this.announce(input.onEvent, {
143
213
  type: "flow.validation.failed",
144
214
  flowId: flow.id,
145
215
  sessionId: key,
146
216
  taskId,
147
217
  runId,
148
- payload: { stage: "extraction", reason: parsed.error }
218
+ payload: { stage: "extraction", reason: error instanceof Error ? error.message : String(error) }
149
219
  });
150
220
  }
151
221
  }
152
- catch (error) {
153
- await this.announce(input.onEvent, {
154
- type: "flow.validation.failed",
155
- flowId: flow.id,
156
- sessionId: key,
157
- taskId,
158
- runId,
159
- payload: { stage: "extraction", reason: error instanceof Error ? error.message : String(error) }
160
- });
161
- }
162
222
  ({ state, requirements, step } = await this.applyPatch(flow, key, taskId, runId, config, state, { set: modelPatch, source: "model" }, requirements, input.onEvent));
163
- // --- Tool-scoped reply pass: never offers tools beyond the active step's policy ---
164
- const effectiveTools = resolveStepTools(this.options.toolRegistrations, step?.tools);
165
- const maxRounds = 6;
166
- const loopInput = {
167
- client: this.options.client,
168
- providerId: this.options.providerId,
169
- model: this.options.model,
170
- messages: [
171
- { role: "system", content: buildGuidedInstructions(config, step, state, this.options.skills) },
172
- ...input.messages,
173
- { role: "user", content: input.text }
174
- ],
175
- tools: effectiveTools,
176
- taskId,
177
- runId,
178
- maxRounds,
179
- onEvent: (event) => this.forwardNativeEvent(flow, key, taskId, runId, event, input.onEvent),
180
- ...(input.signal ? { signal: input.signal } : {})
181
- };
182
- const native = this.options.harness ? await this.options.harness.run(loopInput) : await runNativeToolLoop(loopInput);
183
- const activeStepId = step?.id;
184
- if (activeStepId)
185
- stepToolLog[activeStepId] = stepToolLog[activeStepId] ?? [];
223
+ // --- Tool-scoped reply pass, auto-chaining through consecutive silent tool-only steps ---
224
+ // A step whose only job is "run this required tool — nothing to ask the user" (no
225
+ // `collect`, no `prompt`, has `tools.required`) has no real turn boundary in the source
226
+ // flow: a Typebot conversion commonly chains e.g. `search-client` straight into
227
+ // `search-client-addresses` with no user input between them. Stopping the turn there
228
+ // anyway forces the model to invent SOME reply for a step it has nothing to say about
229
+ // (seen live: after finding the customer record, "¿qué te gustaría ordenar? 🍽️" instead of
230
+ // silently continuing to look up their addresses, like the original bot does). So keep
231
+ // resolving steps within this same turn invisibly to the caller — until landing on one
232
+ // that actually needs the user (has `collect` fields or a literal `prompt`), a tool
233
+ // requests approval, a step fails to advance, or a safety cap is hit. Only the final
234
+ // step's reply is ever shown; every silent step in between never surfaces its own text.
235
+ let currentStep = step;
236
+ let native = { text: "", messages: [], toolCalls: [], toolResults: [], rounds: 0 };
186
237
  let pendingApproval = false;
187
- for (const toolResult of native.toolResults) {
188
- if (toolResult.result.status === "pending_approval")
238
+ let activeStepId;
239
+ // Whether the very first step this turn resolves to (before any tool ran) is one the user
240
+ // was ALREADY sitting on — as opposed to one this turn's own extraction patch just walked
241
+ // them into (e.g. a single-enum step whose exit condition the deterministic matcher just
242
+ // satisfied). Matters only for the collect+verbatimTextArg auto-invoke path below: `rawText`
243
+ // is this turn's message, which was addressed to whatever step was active BEFORE this turn
244
+ // started — feeding it into a step reached for the first time this same turn (which hasn't
245
+ // even had a chance to ask its own question yet) would resolve a free-text tool against
246
+ // words that were never an answer to it. Reproduced live: confirming a previous step with
247
+ // "si" landed on a fresh date-collection step and immediately fed that same "si" into
248
+ // `delivery.validate_date`, which understandably found no date in it and left a blank reply.
249
+ // A brand new session (no prior turn at all — `previousStepId` undefined) is the one
250
+ // exception: there's no earlier step this message could have been misdirected from, so its
251
+ // very first step is fair game even on turn one. That exception does NOT hold when this flow
252
+ // was just entered THIS turn via multi-flow routing (`flowJustSelectedThisTurn`) — there
253
+ // `input.text` is the phrase that caused the routing, not an answer to this flow's own first
254
+ // question, same root cause as the `matchSinglePlainTextCollect` guard above.
255
+ const rawTextBelongsToStep = (previousStepId === undefined && !flowJustSelectedThisTurn) || step?.id === previousStepId;
256
+ for (let chainLength = 1; chainLength <= MAX_CHAIN_STEPS; chainLength += 1) {
257
+ activeStepId = currentStep?.id;
258
+ if (activeStepId)
259
+ stepToolLog[activeStepId] = stepToolLog[activeStepId] ?? [];
260
+ const effectiveTools = resolveStepTools(this.options.toolRegistrations, currentStep?.tools);
261
+ // A step with a literal `prompt` and zero available tools has nothing for the model to
262
+ // decide: there's no tool call it could make, and the only thing left to say is the
263
+ // step's own scripted text. Skip the reply-composition call entirely instead of trusting
264
+ // a model (any model, of any size) to faithfully reproduce that text rather than drift
265
+ // toward something plausible-sounding but off-script.
266
+ // A silent tool-only step (no `collect`, no `prompt`, exactly one required tool) has
267
+ // nothing for a model to decide either: which tool to call isn't ambiguous, and if every
268
+ // one of that tool's input properties already has a same-named value in `state` (most of
269
+ // them do — they're typically the previous step's own tool-output fields), the call can
270
+ // be constructed and executed directly. A model asked to do the exact same thing will
271
+ // sometimes narrate ("let me check that for you") instead of actually emitting the tool
272
+ // call, silently stalling the chain — this path can't drift that way because there's no
273
+ // free-text composition step to drift in. Falls back to the model when an argument can't
274
+ // be confidently resolved from state, so this only ever fires when it's safe to.
275
+ const canUseRawTextForAutoInvoke = chainLength > 1 || rawTextBelongsToStep;
276
+ const autoInvoked = await this.tryAutoInvokeRequiredTool(currentStep, effectiveTools, state, canUseRawTextForAutoInvoke ? input.text : undefined, taskId, runId);
277
+ native =
278
+ autoInvoked ??
279
+ (currentStep?.prompt && effectiveTools.length === 0
280
+ ? { text: interpolatePrompt(currentStep.prompt, state), messages: [], toolCalls: [], toolResults: [], rounds: 0 }
281
+ : await this.runReplyPass(config, currentStep, state, input, effectiveTools, taskId, runId, flow, key, canUseRawTextForAutoInvoke));
282
+ let roundPendingApproval = false;
283
+ for (const toolResult of native.toolResults) {
284
+ if (toolResult.result.status === "pending_approval")
285
+ roundPendingApproval = true;
286
+ if (toolResult.result.status === "ok" && activeStepId) {
287
+ stepToolLog[activeStepId] = [...(stepToolLog[activeStepId] ?? []), toolResult.tool.id];
288
+ }
289
+ const binding = config.tools?.bindings?.[toolResult.tool.id];
290
+ if (binding) {
291
+ const patch = binding.applyResult({ result: toolResult.result, state });
292
+ if (patch) {
293
+ ({ state, requirements, step: currentStep } = await this.applyPatch(flow, key, taskId, runId, config, state, patch, requirements, input.onEvent));
294
+ }
295
+ }
296
+ }
297
+ if (roundPendingApproval)
189
298
  pendingApproval = true;
190
- if (toolResult.result.status === "ok" && activeStepId) {
191
- stepToolLog[activeStepId] = [...(stepToolLog[activeStepId] ?? []), toolResult.tool.id];
299
+ // Don't let the resolved step silently skip past one whose requiredBeforeExit tools haven't run yet.
300
+ let finalStep = resolveActiveStep(config, state);
301
+ if (activeStepId && finalStep?.id !== activeStepId) {
302
+ const previousStepDef = config.steps.find((candidate) => candidate.id === activeStepId);
303
+ const stillMissing = missingRequiredBeforeExitTools(previousStepDef?.tools, stepToolLog[activeStepId] ?? []);
304
+ if (stillMissing.length)
305
+ finalStep = previousStepDef;
192
306
  }
193
- const binding = config.tools?.bindings?.[toolResult.tool.id];
194
- if (binding) {
195
- const patch = binding.applyResult({ result: toolResult.result, state });
196
- if (patch) {
197
- ({ state, requirements, step } = await this.applyPatch(flow, key, taskId, runId, config, state, patch, requirements, input.onEvent));
198
- }
307
+ const stepChanged = Boolean(activeStepId) && finalStep?.id !== activeStepId;
308
+ // The reply pass's system prompt reflected the step this round STARTED on — not
309
+ // necessarily this one. A tool call inside this round can advance the flow past that
310
+ // step before the reply is even composed, but the model was never told what the NEW
311
+ // step asks, so it improvises. When the step actually changed and the new step has a
312
+ // literal `prompt`, use that instead of the improvised text — the same model-agnostic
313
+ // guarantee as the pre-round verbatim path, just applied to a transition that only
314
+ // became knowable mid-round. Unlike the pre-round check, this doesn't require the new
315
+ // step to have zero available tools: the user's message this round was about the step
316
+ // we're LEAVING, not the one we just landed on, so nothing has denied that step's own
317
+ // tool a fair chance yet — it gets one on the next external turn, once the user actually
318
+ // responds to what `prompt` is now asking.
319
+ if (stepChanged && finalStep?.prompt) {
320
+ native = { ...native, text: interpolatePrompt(finalStep.prompt, state) };
199
321
  }
322
+ await this.announceStepChange(flow, key, activeStepId, finalStep?.id, taskId, runId, input.onEvent);
323
+ currentStep = finalStep;
324
+ const isSilentToolOnlyStep = Boolean(currentStep &&
325
+ (!currentStep.collect || currentStep.collect.length === 0) &&
326
+ !currentStep.prompt &&
327
+ (currentStep.tools?.required?.length ?? 0) > 0);
328
+ if (roundPendingApproval || !stepChanged || !isSilentToolOnlyStep)
329
+ break;
200
330
  }
201
- // Don't let the resolved step silently skip past one whose requiredBeforeExit tools haven't run yet.
202
- let finalStep = resolveActiveStep(config, state);
203
- if (activeStepId && finalStep?.id !== activeStepId) {
204
- const previousStepDef = config.steps.find((candidate) => candidate.id === activeStepId);
205
- const stillMissing = missingRequiredBeforeExitTools(previousStepDef?.tools, stepToolLog[activeStepId] ?? []);
206
- if (stillMissing.length)
207
- finalStep = previousStepDef;
208
- }
209
- step = finalStep;
210
- await this.announceStepChange(flow, key, activeStepId, step?.id, taskId, runId, input.onEvent);
331
+ step = currentStep;
332
+ // Last-resort safety net: an auto-invoked tool that didn't advance the step (no `message`
333
+ // on its output, or the step's own prompt wasn't already substituted above) should still
334
+ // never leave the user looking at a blank reply — fall back to the step's own literal
335
+ // prompt, interpolated against the now-current state, if it has one.
336
+ const replyText = native.text || (step?.prompt ? interpolatePrompt(step.prompt, state) : native.text);
211
337
  requirements = evaluateRequirements(config, state);
212
338
  const missingRequirementIds = requirements.filter((requirement) => !requirement.satisfied).map((requirement) => requirement.id);
213
339
  const completedRequirementIds = requirements.filter((requirement) => requirement.satisfied).map((requirement) => requirement.id);
@@ -242,7 +368,7 @@ export class GuidedFlowRuntime {
242
368
  status = "waiting_for_approval";
243
369
  await this.announce(input.onEvent, { type: "flow.waiting_for_approval", flowId: flow.id, sessionId: key, taskId, runId, payload: {} });
244
370
  }
245
- else if (native.rounds >= maxRounds && native.toolCalls.length > 0) {
371
+ else if (native.rounds >= MAX_REPLY_ROUNDS && native.toolCalls.length > 0) {
246
372
  status = "waiting_for_tool";
247
373
  }
248
374
  else {
@@ -266,7 +392,7 @@ export class GuidedFlowRuntime {
266
392
  };
267
393
  await this.options.store.saveFlowState(updated);
268
394
  return {
269
- text: native.text,
395
+ text: replyText,
270
396
  ...(output !== undefined ? { output } : {}),
271
397
  state: state,
272
398
  flow: {
@@ -282,6 +408,123 @@ export class GuidedFlowRuntime {
282
408
  }
283
409
  };
284
410
  }
411
+ /** The model-driven reply pass: composes text and may call the active step's tools. Only reached when a step has no `prompt`, or does but still has tools available (e.g. a validation step, which needs the model to react to a tool result/error dynamically). */
412
+ async runReplyPass(config, step, state, input, effectiveTools, taskId, runId, flow, key, allowVerbatimTextArg = true) {
413
+ // `verbatimTextArg` forces whatever the model composes to be overwritten with this turn's
414
+ // raw text — the right call when the text is actually the user's answer to `step`'s own
415
+ // question, but wrong the moment `step` was only just reached this same turn (e.g. a "sí"
416
+ // confirming the PREVIOUS step landed here via the deterministic enum patch, before the
417
+ // user ever saw this step's own prompt). Forcing it there doesn't just risk a bad guess —
418
+ // it guarantees one, since whatever the model supplies gets discarded either way. Caught
419
+ // live: that exact "sí" got forced into `customer.validate_email_2`'s phone argument,
420
+ // failing validation for a phone number the user never got a chance to type.
421
+ const verbatimMapping = allowVerbatimTextArg ? step?.tools?.verbatimTextArg : undefined;
422
+ const loopInput = {
423
+ client: this.options.client,
424
+ providerId: this.options.providerId,
425
+ model: this.options.model,
426
+ messages: [
427
+ { role: "system", content: buildGuidedInstructions(config, step, state, this.options.skills) },
428
+ ...input.messages,
429
+ { role: "user", content: input.text }
430
+ ],
431
+ tools: applyVerbatimTextArgs(effectiveTools, verbatimMapping, input.text),
432
+ taskId,
433
+ runId,
434
+ maxRounds: MAX_REPLY_ROUNDS,
435
+ onEvent: (event) => this.forwardNativeEvent(flow, key, taskId, runId, event, input.onEvent),
436
+ ...(input.signal ? { signal: input.signal } : {})
437
+ };
438
+ return this.options.harness ? await this.options.harness.run(loopInput) : await runNativeToolLoop(loopInput);
439
+ }
440
+ /**
441
+ * Deterministically calls a step's single applicable tool when its arguments are fully
442
+ * resolvable without asking a model to decide anything. Covers two shapes:
443
+ *
444
+ * 1. A silent tool-only step (no `collect`, exactly one required tool) where every input
445
+ * property already resolves to a known value in `state` (matched by property name,
446
+ * case-insensitively — the generator's convention of lowercase-flattened field names makes
447
+ * this a reliable match in practice).
448
+ * 2. A `collect` step whose only useful action is resolving the user's free text against a
449
+ * dynamic list (an address, a product, a time slot): exactly one tool in `tools.allowed`
450
+ * has a `verbatimTextArg` mapping configured. Which tool to call isn't ambiguous and the
451
+ * argument that matters most — the user's own words — is already pinned to the raw turn
452
+ * text, so there's nothing left for a model to compose either. (Caught live: asked to do
453
+ * the exact same thing, a model sometimes narrates — "voy a consultar..." — instead of
454
+ * actually emitting the tool call, leaving the user with a blank reply.)
455
+ *
456
+ * Returns `undefined` — never partially constructed input — the moment any other required
457
+ * property can't be confidently resolved, so the caller falls back to the model-driven
458
+ * `runReplyPass` exactly as before. Bypasses the harness too: this never asks a model
459
+ * anything, so a custom `ModelHarness` has nothing to intercept here (same reasoning as the
460
+ * `step.prompt` verbatim path).
461
+ */
462
+ async tryAutoInvokeRequiredTool(step, effectiveTools, state, rawText, taskId, runId) {
463
+ const requiredIds = step?.tools?.required ?? [];
464
+ const hasCollect = (step?.collect?.length ?? 0) > 0;
465
+ let toolId;
466
+ let verbatimArgName;
467
+ if (!hasCollect && requiredIds.length === 1) {
468
+ toolId = requiredIds[0];
469
+ }
470
+ else if (hasCollect && rawText !== undefined) {
471
+ const verbatimMapping = step?.tools?.verbatimTextArg;
472
+ const allowedIds = step?.tools?.allowed ?? [];
473
+ const verbatimIds = verbatimMapping ? allowedIds.filter((id) => verbatimMapping[id]) : [];
474
+ const soleVerbatimId = verbatimIds.length === 1 ? verbatimIds[0] : undefined;
475
+ if (soleVerbatimId) {
476
+ toolId = soleVerbatimId;
477
+ verbatimArgName = verbatimMapping?.[soleVerbatimId];
478
+ }
479
+ }
480
+ if (!toolId)
481
+ return undefined;
482
+ const registration = effectiveTools.find((candidate) => candidate.tool.id === toolId);
483
+ if (!registration)
484
+ return undefined;
485
+ const schema = registration.tool.inputSchema;
486
+ const propertyNames = Object.keys(schema?.properties ?? {});
487
+ const requiredPropertyNames = schema?.required ?? propertyNames;
488
+ const stateEntries = Object.entries(state);
489
+ const args = {};
490
+ for (const propertyName of propertyNames) {
491
+ // `verbatimArgName` is only ever set above once `rawText !== undefined` was confirmed —
492
+ // this narrows what TypeScript can't infer across the two functions.
493
+ if (propertyName === verbatimArgName && rawText !== undefined) {
494
+ args[propertyName] = rawText;
495
+ continue;
496
+ }
497
+ const value = resolveArgFromState(propertyName, stateEntries);
498
+ if (value === undefined || value === null || value === "") {
499
+ if (requiredPropertyNames.includes(propertyName))
500
+ return undefined;
501
+ continue;
502
+ }
503
+ // A loosely-typed state field (the generator emits `z.unknown()` for plenty of collected
504
+ // values) commonly holds a JS type its own tool argument didn't expect — not because
505
+ // anything drifted, but because the extraction call itself just returns valid JSON for
506
+ // whatever the user said: "1" comes back as the NUMBER 1, not the string "1", when the
507
+ // field has no type constraint to anchor it. A tool argument typed `z.string()` then
508
+ // rejects that number outright. Caught live: `numberOfCilinders` silently failing to
509
+ // resolve this way left `order.call` invoked with the count missing entirely, and the
510
+ // real API rejected the whole order with a generic, unhelpful error. Coercing between
511
+ // closely-related primitives (number/boolean → string, numeric string → number) covers
512
+ // this safely; anything further off (an object where a string was expected, say) still
513
+ // bails rather than guessing.
514
+ const coerced = coerceToJsonSchemaType(value, schema?.properties?.[propertyName]?.type);
515
+ if (!coerced.ok)
516
+ return undefined;
517
+ args[propertyName] = coerced.value;
518
+ }
519
+ const result = await registration.handler(args, { taskId, runId, stepId: `auto-chain-${registration.tool.id}-${randomUUID()}` });
520
+ return {
521
+ text: extractResultMessage(result),
522
+ messages: [],
523
+ toolCalls: [],
524
+ toolResults: [{ tool: registration.tool, result, toolCallId: `auto-${randomUUID()}` }],
525
+ rounds: 0
526
+ };
527
+ }
285
528
  async applyPatch(flow, key, taskId, runId, config, state, patch, previousRequirements, onEvent) {
286
529
  await this.options.hooks.fire("beforeFlowStatePatch", { flowId: flow.id, sessionId: key, patch, context: { taskId, runId } });
287
530
  const applied = applyStatePatch(config, state, patch);
@@ -413,6 +656,303 @@ export class GuidedFlowRuntime {
413
656
  return this.options.store.getFlowState(key, flow.id);
414
657
  }
415
658
  }
659
+ /**
660
+ * Substitutes `{{fieldName}}` placeholders in a step's literal `prompt` with the live value of
661
+ * that field in `state` — e.g. a Typebot bubble that's literally `{{addressesListMessage}}`
662
+ * (a previous tool's own output, meant to be shown to the user verbatim) becomes the real
663
+ * address list text instead of vanishing or showing raw template syntax. An unresolved or
664
+ * still-empty field renders as `""` rather than leaking `{{...}}` to the user — this can only
665
+ * happen if `prompt` is reached before the tool that was supposed to populate it has actually
666
+ * run, which the `denied`-tools-by-default step policy is what prevents in practice.
667
+ */
668
+ /**
669
+ * Best-effort text for an auto-invoked tool's result, so a step that doesn't advance (a failed
670
+ * free-text match, a validation rejection) doesn't leave the user staring at a blank reply — the
671
+ * same failure mode `resolveArgFromState`'s auto-invoke path is meant to eliminate, just on the
672
+ * output side instead of the input side. Purely additive: when the tool's output has no string
673
+ * `message` field, this returns "" exactly as before, and a later step-change in the same turn
674
+ * (see the `stepChanged && finalStep?.prompt` override) still takes priority over this text.
675
+ */
676
+ function extractResultMessage(result) {
677
+ if (result.status !== "ok")
678
+ return "";
679
+ const output = result.output;
680
+ if (output && typeof output === "object" && !Array.isArray(output) && "message" in output) {
681
+ const message = output.message;
682
+ return typeof message === "string" ? message : "";
683
+ }
684
+ return "";
685
+ }
686
+ function interpolatePrompt(prompt, state) {
687
+ return prompt.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (match, field) => {
688
+ const value = state[field];
689
+ return value === undefined || value === null ? "" : String(value);
690
+ });
691
+ }
692
+ /**
693
+ * Resolves a tool's input property (e.g. `addressId`) against `state`, for
694
+ * `tryAutoInvokeRequiredTool`. Tries an exact case-insensitive key match first (`customerId` ↔
695
+ * `customerid`); if that finds nothing, falls back to a state key that ENDS WITH the property
696
+ * name (`addressId` ↔ `selectedaddressid`) — only when exactly one key qualifies, so it can
697
+ * never guess between two plausible fields. This second pass matters in practice: generated
698
+ * state field names are commonly prefixed with what produced them (`selected`, `chosen`,
699
+ * `validated`...), so a literal match against the tool's own property name often doesn't
700
+ * exist even though there's exactly one unambiguous field it obviously means. Without it, this
701
+ * step falls through to asking a model to supply the same argument — which is where a real bug
702
+ * showed up: the model narrated "voy a consultar los productos..." instead of actually calling
703
+ * the tool that turn, stalling the flow exactly like the cases this whole mechanism exists to
704
+ * avoid.
705
+ */
706
+ function resolveArgFromState(propertyName, stateEntries) {
707
+ const lowerProperty = propertyName.toLowerCase();
708
+ const exact = stateEntries.find(([key]) => key.toLowerCase() === lowerProperty);
709
+ if (exact)
710
+ return exact[1];
711
+ const suffixMatches = stateEntries.filter(([key]) => {
712
+ const lowerKey = key.toLowerCase();
713
+ return lowerKey.length > lowerProperty.length && lowerKey.endsWith(lowerProperty);
714
+ });
715
+ if (suffixMatches.length !== 1)
716
+ return undefined;
717
+ const match = suffixMatches[0];
718
+ return match ? match[1] : undefined;
719
+ }
720
+ /**
721
+ * Whether `value` is a plausible fit for a JSON Schema `type` keyword ("string", "number",
722
+ * "integer", "boolean", "array", "object") before it's handed to a tool's own (stricter) Zod
723
+ * validation. Deliberately permissive when `expectedType` is missing/unrecognized — the caller
724
+ * only uses this to bail out of an auto-invoke early, never to accept a value it otherwise
725
+ * wouldn't have.
726
+ */
727
+ /**
728
+ * Checks a resolved state value against a tool argument's declared JSON Schema `type`, coercing
729
+ * between closely-related PRIMITIVES (number/boolean → string, a numeric string → number) rather
730
+ * than just accepting-or-rejecting — an extraction call returning `1` for a field the user
731
+ * answered with the free-text "1" is the ordinary case, not a corruption, and a `z.string()`
732
+ * argument should get `"1"` for it rather than silently losing the whole tool call. Structured
733
+ * mismatches (an object/array where a primitive was expected, or vice versa) are never coerced —
734
+ * those really do indicate the wrong value ended up in the wrong field, and this rejects
735
+ * (`ok: false`) exactly as before so the caller falls back to the model.
736
+ */
737
+ function coerceToJsonSchemaType(value, expectedType) {
738
+ switch (expectedType) {
739
+ case "string":
740
+ if (typeof value === "string")
741
+ return { ok: true, value };
742
+ if (typeof value === "number" || typeof value === "boolean")
743
+ return { ok: true, value: String(value) };
744
+ return { ok: false };
745
+ case "number":
746
+ case "integer":
747
+ if (typeof value === "number")
748
+ return { ok: true, value };
749
+ if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value)))
750
+ return { ok: true, value: Number(value) };
751
+ return { ok: false };
752
+ case "boolean":
753
+ return typeof value === "boolean" ? { ok: true, value } : { ok: false };
754
+ case "array":
755
+ return Array.isArray(value) ? { ok: true, value } : { ok: false };
756
+ case "object":
757
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? { ok: true, value } : { ok: false };
758
+ default:
759
+ return { ok: true, value };
760
+ }
761
+ }
762
+ /**
763
+ * Wraps any tool registration named in `mapping` so its handler always receives the current
764
+ * turn's raw text under the mapped argument name, overwriting whatever the model itself put
765
+ * there. See `StepToolPolicy.verbatimTextArg` for why: a model composing that argument from
766
+ * context can invent a plausible-sounding value instead of relaying what the user actually
767
+ * said, especially for a vague reply — this makes the tool's own "does this resolve to
768
+ * anything?" check trustworthy, since it's always judging the real input.
769
+ */
770
+ function applyVerbatimTextArgs(tools, mapping, rawText) {
771
+ if (!mapping || Object.keys(mapping).length === 0)
772
+ return tools;
773
+ return tools.map((registration) => {
774
+ const argName = mapping[registration.tool.id];
775
+ if (!argName)
776
+ return registration;
777
+ return {
778
+ ...registration,
779
+ handler: (rawInput, context) => registration.handler({ ...rawInput, [argName]: rawText }, context)
780
+ };
781
+ });
782
+ }
783
+ function unwrapEnum(schema) {
784
+ if (schema instanceof z.ZodEnum)
785
+ return schema;
786
+ if (schema instanceof z.ZodOptional || schema instanceof z.ZodDefault || schema instanceof z.ZodNullable) {
787
+ return unwrapEnum(schema._def.innerType);
788
+ }
789
+ return undefined;
790
+ }
791
+ function unwrapZodString(schema) {
792
+ if (schema instanceof z.ZodString)
793
+ return schema;
794
+ if (schema instanceof z.ZodOptional || schema instanceof z.ZodDefault || schema instanceof z.ZodNullable) {
795
+ return unwrapZodString(schema._def.innerType);
796
+ }
797
+ return undefined;
798
+ }
799
+ /**
800
+ * The extraction call's own instruction says "omit fields that are unknown, already known, or
801
+ * not mentioned" — but with `responseFormat: json_object` (see the extraction call site below)
802
+ * the model sometimes satisfies that by writing an empty string instead of actually omitting the
803
+ * key, especially for enum/number fields it isn't confident about (`paymentMethod: ""` against
804
+ * an enum, `quantity: ""` against a number — both fail `schema.parse()` the same way "prose
805
+ * instead of JSON" used to, just with a Zod error instead of a JSON.parse error). An empty string
806
+ * IS a valid, intentional value for a plain free-text field though (e.g. a flow's own "comments"
807
+ * field, whose step instructions explicitly tell the model to record "" for "no comment") — only
808
+ * strip it from fields whose schema does NOT accept a bare string, where "" can only ever be the
809
+ * model failing to omit, never a real answer.
810
+ */
811
+ function stripEmptyStringsOnNonStringFields(schema, value) {
812
+ if (!value || typeof value !== "object" || Array.isArray(value))
813
+ return value;
814
+ if (!(schema instanceof z.ZodObject))
815
+ return value;
816
+ const shape = schema.shape;
817
+ const record = value;
818
+ const result = { ...record };
819
+ for (const [key, fieldValue] of Object.entries(record)) {
820
+ if (fieldValue !== "")
821
+ continue;
822
+ const fieldSchema = shape[key];
823
+ if (fieldSchema && !unwrapZodString(fieldSchema))
824
+ delete result[key];
825
+ }
826
+ return result;
827
+ }
828
+ const COMBINING_DIACRITICS = new RegExp("[\\u0300-\\u036f]", "g");
829
+ const normalizeChoiceText = (value) => value
830
+ .normalize("NFD")
831
+ .replace(COMBINING_DIACRITICS, "")
832
+ .trim()
833
+ .toLowerCase();
834
+ /**
835
+ * Matches raw user text against a single enum-typed `collect` field's options — by 1-based
836
+ * list index (matching the numbered list `step.prompt` renders), exact label (accent- and
837
+ * case-insensitive), or an unambiguous partial match (exactly one option contains the text or
838
+ * vice versa, e.g. "quiero el tanque" or a bare "tanque" both resolving "Tanque"). Returns
839
+ * `undefined` — never a guess — the moment more than one option could plausibly match, or the
840
+ * step isn't a single-enum choice at all, so the caller always has a safe fallback to the
841
+ * model-driven extraction call.
842
+ */
843
+ /**
844
+ * Identifies a step's single enum-typed `collect` field, if it has exactly one — the same
845
+ * precondition `matchSingleEnumChoice` requires to even attempt a deterministic match. Exposed
846
+ * separately so the caller can tell "no deterministic match was possible" apart from "this
847
+ * field isn't even a bounded-choice one", which matters for deciding whether to trust the
848
+ * extraction model with it at all (see the call site in `driveFlow`).
849
+ */
850
+ function singleEnumCollectField(stateSchema, step) {
851
+ if (step?.collect?.length !== 1)
852
+ return undefined;
853
+ const field = String(step.collect[0]);
854
+ const fieldSchema = stateSchema.shape[field];
855
+ return fieldSchema && unwrapEnum(fieldSchema) ? field : undefined;
856
+ }
857
+ function matchSingleEnumChoice(stateSchema, step, rawText) {
858
+ const field = singleEnumCollectField(stateSchema, step);
859
+ if (!field)
860
+ return undefined;
861
+ const fieldSchema = stateSchema.shape[field];
862
+ const enumSchema = fieldSchema && unwrapEnum(fieldSchema);
863
+ if (!enumSchema)
864
+ return undefined;
865
+ const options = enumSchema.options;
866
+ const text = normalizeChoiceText(rawText);
867
+ if (!text)
868
+ return undefined;
869
+ const index = Number(text);
870
+ if (Number.isInteger(index) && index >= 1 && index <= options.length) {
871
+ return { [field]: options[index - 1] };
872
+ }
873
+ const exact = options.filter((option) => normalizeChoiceText(option) === text);
874
+ if (exact.length === 1)
875
+ return { [field]: exact[0] };
876
+ const partial = options.filter((option) => {
877
+ const normalizedOption = normalizeChoiceText(option);
878
+ return normalizedOption.includes(text) || text.includes(normalizedOption);
879
+ });
880
+ if (partial.length === 1)
881
+ return { [field]: partial[0] };
882
+ // Common Spanish yes/no colloquialisms ("sip", "va", "nel"...) share no substring with a
883
+ // label like "Sí es correcto" or "Continuar" — the checks above miss them entirely, which
884
+ // regressed a real confirm step live: "sip" stopped resolving "Sí es correcto" once the
885
+ // extraction call was barred from guessing this field at all (see the enum-field exclusion
886
+ // below), leaving affirmations that don't literally overlap with the option text with no path
887
+ // to resolve short of a second try. This only fires when exactly one option's own wording is
888
+ // recognizably yes-flavored (or no-flavored) and the other isn't — never for option pairs like
889
+ // "Elegir de la lista" / "Agregar domicilio" that aren't a yes/no choice in the first place.
890
+ const polarityMatch = matchYesNoColloquialism(options, text);
891
+ if (polarityMatch)
892
+ return { [field]: polarityMatch };
893
+ return undefined;
894
+ }
895
+ const AFFIRMATIVE_OPTION_WORDS = ["si", "correcto", "confirmar", "confirmo", "continuar", "aceptar", "acepto"];
896
+ const NEGATIVE_OPTION_WORDS = ["no", "corregir", "cancelar", "incorrecto", "rechazar", "cambiar"];
897
+ const AFFIRMATIVE_SYNONYMS = new Set(["si", "sip", "sisas", "simon", "claro", "va", "dale", "ok", "okay", "correcto", "afirmativo"]);
898
+ const NEGATIVE_SYNONYMS = new Set(["no", "nel", "nop", "negativo", "paranada", "nones"]);
899
+ function optionPolarity(normalizedOption) {
900
+ const isAffirmative = AFFIRMATIVE_OPTION_WORDS.some((word) => normalizedOption.includes(word));
901
+ const isNegative = NEGATIVE_OPTION_WORDS.some((word) => normalizedOption.includes(word));
902
+ if (isAffirmative && !isNegative)
903
+ return "yes";
904
+ if (isNegative && !isAffirmative)
905
+ return "no";
906
+ return undefined;
907
+ }
908
+ function matchYesNoColloquialism(options, text) {
909
+ const textPolarity = AFFIRMATIVE_SYNONYMS.has(text) ? "yes" : NEGATIVE_SYNONYMS.has(text) ? "no" : undefined;
910
+ if (!textPolarity)
911
+ return undefined;
912
+ const matches = options.filter((option) => optionPolarity(normalizeChoiceText(option)) === textPolarity);
913
+ return matches.length === 1 ? matches[0] : undefined;
914
+ }
915
+ /**
916
+ * A step's single `collect` field qualifies for verbatim capture — the raw turn text becomes
917
+ * its value with no model involved — only under conditions narrow enough that there's nothing
918
+ * left to decide:
919
+ * - Exactly one `collect` field (a multi-field step genuinely needs the model to work out which
920
+ * part of the text answers which still-open question).
921
+ * - Not an enum (that's `matchSingleEnumChoice`'s job — a bounded answer space needs matching,
922
+ * not verbatim capture).
923
+ * - The step has no `allowed`/`required` tools at all — if a tool exists, an argument might need
924
+ * real composition or validation (that's what `verbatimTextArg` + the tool's own logic are
925
+ * for); this only covers steps whose entire job is "remember exactly what they typed."
926
+ */
927
+ function isPlainTextSchema(schema) {
928
+ if (schema instanceof z.ZodOptional || schema instanceof z.ZodDefault || schema instanceof z.ZodNullable) {
929
+ return isPlainTextSchema(schema._def.innerType);
930
+ }
931
+ // Deliberately narrow: booleans/numbers/objects/arrays need real parsing (or come from a
932
+ // confirmation step, a derived value, a tool result) — verbatim raw text would either fail
933
+ // that field's own validation or silently coerce into something the user never intended.
934
+ // The generator's convention for free-text fields is `z.unknown()`; `z.string()`/`z.any()`
935
+ // cover the rest of what a plain "remember what they typed" field could reasonably be.
936
+ return schema instanceof z.ZodString || schema instanceof z.ZodUnknown || schema instanceof z.ZodAny;
937
+ }
938
+ function singlePlainTextCollectField(stateSchema, step) {
939
+ if (step?.collect?.length !== 1)
940
+ return undefined;
941
+ if ((step.tools?.allowed?.length ?? 0) > 0 || (step.tools?.required?.length ?? 0) > 0)
942
+ return undefined;
943
+ const field = String(step.collect[0]);
944
+ const fieldSchema = stateSchema.shape[field];
945
+ if (!fieldSchema || unwrapEnum(fieldSchema) || !isPlainTextSchema(fieldSchema))
946
+ return undefined;
947
+ return field;
948
+ }
949
+ function matchSinglePlainTextCollect(stateSchema, step, rawText) {
950
+ const field = singlePlainTextCollectField(stateSchema, step);
951
+ if (!field)
952
+ return undefined;
953
+ const text = rawText.trim();
954
+ return text ? { [field]: text } : undefined;
955
+ }
416
956
  function buildGuidedInstructions(config, step, state, skills) {
417
957
  const known = Object.entries(state)
418
958
  .filter(([, value]) => value !== undefined && value !== null && value !== "")
@@ -421,11 +961,28 @@ function buildGuidedInstructions(config, step, state, skills) {
421
961
  const stepSkills = (step?.skills ?? [])
422
962
  .map((id) => skills.find((skill) => skill.id === id))
423
963
  .filter((skill) => Boolean(skill));
964
+ // A step with nothing to collect and a required tool is a pure backend step — there is
965
+ // nothing to narrate or confirm yet, only a tool to call. Left to its own judgment, a model
966
+ // will sometimes announce "let me check that for you" as plain text instead of actually
967
+ // emitting the tool call in that same response, which silently stalls the auto-chain in
968
+ // `driveFlow` (it only keeps chaining once the tool has actually run). State this as an
969
+ // imperative instruction rather than leaving it implicit in `instructions`.
970
+ const requiredTools = step?.tools?.required ?? [];
971
+ const mustCallNow = !step?.collect?.length && requiredTools.length > 0
972
+ ? `This step has exactly one job: call ${requiredTools.map((id) => `"${String(id)}"`).join(", ")} right now, in this response. Do not narrate, explain, or say you're about to do it — call the tool immediately, with no preceding text.`
973
+ : "";
424
974
  const lines = [
425
975
  `You are guiding the user through the "${config.name}" flow. Goal: ${config.goal}`,
426
976
  known.length ? `Known so far: ${known.join(", ")}.` : "Nothing is known about the user yet.",
427
977
  missing.length ? `Still missing: ${missing.join(", ")}. Ask only for what's missing — never re-ask for known fields.` : "",
428
- step?.instructions ? `Current step instructions: ${step.instructions}` : "",
978
+ // `instructions` can carry the same `{{field}}` placeholders `prompt` does — e.g. an error
979
+ // message field that's only meaningful after a failed validation. Left un-interpolated, a
980
+ // model composing a reply from these instructions has been observed to just echo the raw
981
+ // `{{errormessagephone}}` syntax back to the user verbatim instead of treating it as
982
+ // internal templating. `prompt` gets this via `interpolatePrompt` at render time; do the
983
+ // same here since `instructions` also ends up as literal text in front of the model.
984
+ step?.instructions ? `Current step instructions: ${interpolatePrompt(step.instructions, state)}` : "",
985
+ mustCallNow,
429
986
  ...stepSkills.map((skill) => `Skill "${skill.id}": ${skill.instructions}`)
430
987
  ];
431
988
  return lines.filter(Boolean).join("\n");