@nexalab/agent-sdk 0.1.6 → 0.1.8

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